mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
staticaddr/loopin: wait for risk acceptance notification
Wait for the server's static loop-in risk-accepted notification before starting the client payment deadline. The server may intentionally hold the swap at the confirmation-risk gate after HTLC signing, and the client deadline should not run while that server-side wait is still in progress. Cache risk-accepted notifications by swap hash inside the local notification manager and replay them to the per-swap subscriber. This covers both reconnects and the internal race where the global notification stream receives the server event before the static loop-in FSM registers its waiter.
This commit is contained in:
parent
a7bff02fd8
commit
aa77682e07
6 changed files with 792 additions and 35 deletions
|
|
@ -26,6 +26,10 @@ const (
|
|||
// static loop in sweep requests.
|
||||
NotificationTypeStaticLoopInSweepRequest
|
||||
|
||||
// NotificationTypeStaticLoopInRiskAccepted is the notification type for
|
||||
// static loop in confirmation risk acceptance.
|
||||
NotificationTypeStaticLoopInRiskAccepted
|
||||
|
||||
// NotificationTypeUnfinishedSwap is the notification type for unfinished
|
||||
// swap notifications.
|
||||
NotificationTypeUnfinishedSwap
|
||||
|
|
@ -76,6 +80,9 @@ type Manager struct {
|
|||
hasL402 bool
|
||||
|
||||
subscribers map[NotificationType][]subscriber
|
||||
|
||||
staticLoopInRiskAccepted map[lntypes.Hash]*swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification
|
||||
}
|
||||
|
||||
// NewManager creates a new notification manager.
|
||||
|
|
@ -88,6 +95,10 @@ func NewManager(cfg *Config) *Manager {
|
|||
return &Manager{
|
||||
cfg: cfg,
|
||||
subscribers: make(map[NotificationType][]subscriber),
|
||||
staticLoopInRiskAccepted: make(
|
||||
map[lntypes.Hash]*swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -143,6 +154,42 @@ func (m *Manager) SubscribeStaticLoopInSweepRequests(ctx context.Context,
|
|||
return notifChan
|
||||
}
|
||||
|
||||
// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk accepted
|
||||
// notifications.
|
||||
func (m *Manager) SubscribeStaticLoopInRiskAccepted(ctx context.Context,
|
||||
swapHash lntypes.Hash,
|
||||
) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification {
|
||||
|
||||
notifChan := make(
|
||||
chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification, 1,
|
||||
)
|
||||
|
||||
sub := subscriber{
|
||||
subCtx: ctx,
|
||||
recvChan: notifChan,
|
||||
}
|
||||
|
||||
m.Lock()
|
||||
m.subscribers[NotificationTypeStaticLoopInRiskAccepted] = append(
|
||||
m.subscribers[NotificationTypeStaticLoopInRiskAccepted], sub,
|
||||
)
|
||||
if ntfn, ok := m.staticLoopInRiskAccepted[swapHash]; ok {
|
||||
notifChan <- ntfn
|
||||
delete(m.staticLoopInRiskAccepted, swapHash)
|
||||
}
|
||||
m.Unlock()
|
||||
|
||||
context.AfterFunc(ctx, func() {
|
||||
m.removeSubscriber(NotificationTypeStaticLoopInRiskAccepted, sub)
|
||||
m.Lock()
|
||||
delete(m.staticLoopInRiskAccepted, swapHash)
|
||||
m.Unlock()
|
||||
close(notifChan)
|
||||
})
|
||||
|
||||
return notifChan
|
||||
}
|
||||
|
||||
// SubscribeUnfinishedSwaps subscribes to the unfinished swap notifications.
|
||||
func (m *Manager) SubscribeUnfinishedSwaps(ctx context.Context,
|
||||
) <-chan *swapserverrpc.ServerUnfinishedSwapNotification {
|
||||
|
|
@ -328,6 +375,37 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc.
|
|||
}
|
||||
}
|
||||
|
||||
case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskAccepted: // nolint: lll
|
||||
// We'll forward the static loop in risk accepted notification to all
|
||||
// subscribers.
|
||||
riskAcceptedNtfn := ntfn.GetStaticLoopInRiskAccepted()
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
|
||||
if riskAcceptedNtfn != nil {
|
||||
swapHash, err := lntypes.MakeHash(riskAcceptedNtfn.SwapHash)
|
||||
if err != nil {
|
||||
log.Warnf("Received invalid static loop in risk "+
|
||||
"accepted notification: %v", err)
|
||||
} else {
|
||||
m.staticLoopInRiskAccepted[swapHash] =
|
||||
riskAcceptedNtfn
|
||||
}
|
||||
}
|
||||
|
||||
for _, sub := range m.subscribers[NotificationTypeStaticLoopInRiskAccepted] { // nolint: lll
|
||||
recvChan := sub.recvChan.(chan *swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification)
|
||||
|
||||
select {
|
||||
case recvChan <- riskAcceptedNtfn:
|
||||
case <-sub.subCtx.Done():
|
||||
default:
|
||||
log.Debugf("Dropping static loop in risk " +
|
||||
"accepted notification for slow subscriber")
|
||||
}
|
||||
}
|
||||
|
||||
case *swapserverrpc.SubscribeNotificationsResponse_UnfinishedSwap: // nolint: lll
|
||||
// We'll forward the unfinished swap notification to all
|
||||
// subscribers.
|
||||
|
|
|
|||
|
|
@ -299,6 +299,76 @@ func TestManager_UnfinishedSwapNotificationWaitsForSubscriber(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskAcceptedNotification tests that the Manager
|
||||
// forwards static loop in risk accepted notifications to subscribers.
|
||||
func TestManager_StaticLoopInRiskAcceptedNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr := NewManager(&Config{})
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
defer subCancel()
|
||||
|
||||
swapHash := lntypes.Hash{0x04, 0x05}
|
||||
|
||||
subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)
|
||||
|
||||
mgr.handleNotification(
|
||||
&swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
|
||||
StaticLoopInRiskAccepted: &swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
select {
|
||||
case received := <-subChan:
|
||||
require.Equal(t, swapHash[:], received.SwapHash)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("did not receive risk accepted notification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskAcceptedNotificationReplay tests that the Manager
|
||||
// replays a risk accepted notification that arrives before the swap-specific
|
||||
// subscriber is registered.
|
||||
func TestManager_StaticLoopInRiskAcceptedNotificationReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr := NewManager(&Config{})
|
||||
|
||||
swapHash := lntypes.Hash{0x06, 0x07}
|
||||
mgr.handleNotification(
|
||||
&swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
|
||||
StaticLoopInRiskAccepted: &swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
defer subCancel()
|
||||
|
||||
subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)
|
||||
|
||||
select {
|
||||
case received := <-subChan:
|
||||
require.Equal(t, swapHash[:], received.SwapHash)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("did not replay risk accepted notification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_Backoff verifies that repeated failures in
|
||||
// subscribeNotifications cause the Manager to space out subscription attempts
|
||||
// via a predictable incremental backoff.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package loopin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
|
|
@ -367,6 +368,61 @@ func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) (
|
|||
}
|
||||
}
|
||||
|
||||
// selectedDepositConfirmationHeights returns current confirmation heights for
|
||||
// the original deposit outpoints selected by this loop-in.
|
||||
func selectedDepositConfirmationHeights(
|
||||
loopIn *StaticAddressLoopIn) map[string]int64 {
|
||||
|
||||
confirmations := make(map[string]int64, len(loopIn.Deposits))
|
||||
outpoints := make(map[string]struct{}, len(loopIn.DepositOutpoints))
|
||||
for _, outpoint := range loopIn.DepositOutpoints {
|
||||
outpoints[outpoint] = struct{}{}
|
||||
}
|
||||
|
||||
for _, d := range loopIn.Deposits {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
d.Lock()
|
||||
outpoint := d.OutPoint.String()
|
||||
confirmationHeight := d.ConfirmationHeight
|
||||
d.Unlock()
|
||||
|
||||
if _, ok := outpoints[outpoint]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
confirmations[outpoint] = confirmationHeight
|
||||
}
|
||||
|
||||
return confirmations
|
||||
}
|
||||
|
||||
// legacyMinConfsReached returns true once every original deposit is confirmed
|
||||
// and the youngest original deposit has reached the legacy confirmation target.
|
||||
func legacyMinConfsReached(outpoints []string,
|
||||
confirmationHeights map[string]int64, currentHeight int32) bool {
|
||||
|
||||
if currentHeight <= 0 || len(outpoints) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
youngestConfirmation := int64(0)
|
||||
for _, outpoint := range outpoints {
|
||||
confirmationHeight, ok := confirmationHeights[outpoint]
|
||||
if !ok || confirmationHeight <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if confirmationHeight > youngestConfirmation {
|
||||
youngestConfirmation = confirmationHeight
|
||||
}
|
||||
}
|
||||
|
||||
return int64(currentHeight) >= youngestConfirmation+deposit.MinConfs-1
|
||||
}
|
||||
|
||||
// originalDepositOutpointUnavailable checks the original selected deposit
|
||||
// outpoints against the chain backend's UTXO view.
|
||||
func (f *FSM) originalDepositOutpointUnavailable(ctx context.Context) (
|
||||
|
|
@ -631,7 +687,22 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
return f.HandleError(err)
|
||||
}
|
||||
|
||||
var (
|
||||
riskAcceptedChan <-chan *swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification
|
||||
cancelRiskAcceptedSubscription = func() {}
|
||||
)
|
||||
if f.cfg.NotificationManager != nil {
|
||||
acceptedCtx, cancel := context.WithCancel(ctx)
|
||||
cancelRiskAcceptedSubscription = cancel
|
||||
riskAcceptedChan = f.cfg.NotificationManager.
|
||||
SubscribeStaticLoopInRiskAccepted(
|
||||
acceptedCtx, f.loopIn.SwapHash,
|
||||
)
|
||||
}
|
||||
defer cancelRiskAcceptedSubscription()
|
||||
htlcConfirmed := false
|
||||
depositsUnlocked := false
|
||||
|
||||
invoice, err := f.cfg.LndClient.LookupInvoice(ctx, f.loopIn.SwapHash)
|
||||
if err != nil {
|
||||
|
|
@ -641,30 +712,34 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
return f.HandleError(err)
|
||||
}
|
||||
|
||||
// Create the swap payment timeout timer. If it runs out we cancel the
|
||||
// invoice, but keep monitoring the htlc confirmation.
|
||||
// If the invoice was canceled, e.g. before a restart, we don't need to
|
||||
// set a new deadline.
|
||||
var deadlineChan <-chan time.Time
|
||||
if invoice.State != invoices.ContractCanceled {
|
||||
// If the invoice is still live we set the timeout to the
|
||||
// remaining payment time. If too much time has elapsed, e.g.
|
||||
// after a restart, we set the timeout to 0 to cancel the
|
||||
// invoice and unlock the deposits immediately.
|
||||
remainingTimeSeconds := f.loopIn.RemainingPaymentTimeSeconds()
|
||||
// Create the swap payment timeout timer after the server confirms
|
||||
// confirmation risk was accepted. If a server does not support risk
|
||||
// notifications, fall back after the legacy deposit confirmation depth.
|
||||
var (
|
||||
deadlineChan <-chan time.Time
|
||||
deadlineTimer *time.Timer
|
||||
deadlineStarted bool
|
||||
)
|
||||
defer func() {
|
||||
if deadlineTimer != nil {
|
||||
deadlineTimer.Stop()
|
||||
}
|
||||
}()
|
||||
|
||||
// If the invoice isn't cancelled yet and the payment timeout
|
||||
// elapsed, we set the timeout to 0 to cancel the invoice and
|
||||
// unlock the deposits immediately. Otherwise, we start the
|
||||
// timer with the remaining seconds to timeout.
|
||||
timeout := time.Duration(0) * time.Second
|
||||
if remainingTimeSeconds > 0 {
|
||||
timeout = time.Duration(remainingTimeSeconds) *
|
||||
time.Second
|
||||
startPaymentDeadline := func(reason string) {
|
||||
if deadlineStarted || invoice.State == invoices.ContractCanceled {
|
||||
return
|
||||
}
|
||||
|
||||
deadlineChan = time.NewTimer(timeout).C
|
||||
} else {
|
||||
timeout := f.loopIn.PaymentTimeoutDuration()
|
||||
|
||||
f.Infof("starting payment deadline after %s", reason)
|
||||
deadlineTimer = time.NewTimer(timeout)
|
||||
deadlineChan = deadlineTimer.C
|
||||
deadlineStarted = true
|
||||
}
|
||||
|
||||
if invoice.State == invoices.ContractCanceled {
|
||||
// If the invoice was canceled previously we end our
|
||||
// subscription to invoice updates.
|
||||
cancelInvoiceSubscription()
|
||||
|
|
@ -721,19 +796,51 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
}
|
||||
|
||||
case <-deadlineChan:
|
||||
deadlineChan = nil
|
||||
|
||||
// If the server didn't pay the invoice on time, we
|
||||
// cancel the invoice and keep monitoring the htlc tx
|
||||
// confirmation. We also need to unlock the deposits to
|
||||
// re-enable them for loop-ins and withdrawals.
|
||||
cancelInvoice()
|
||||
|
||||
event := f.UnlockDepositsAction(ctx, nil)
|
||||
if event != fsm.OnError {
|
||||
err := f.unlockDeposits(ctx)
|
||||
if err != nil {
|
||||
f.Errorf("unable to unlock deposits after " +
|
||||
"payment deadline")
|
||||
continue
|
||||
}
|
||||
depositsUnlocked = true
|
||||
|
||||
case riskAccepted, ok := <-riskAcceptedChan:
|
||||
if !ok {
|
||||
riskAcceptedChan = nil
|
||||
continue
|
||||
}
|
||||
|
||||
if !bytes.Equal(
|
||||
riskAccepted.SwapHash, f.loopIn.SwapHash[:],
|
||||
) {
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
startPaymentDeadline("risk accepted notification")
|
||||
|
||||
case currentHeight := <-blockChan:
|
||||
depositConfirmationHeights :=
|
||||
selectedDepositConfirmationHeights(f.loopIn)
|
||||
|
||||
if legacyMinConfsReached(
|
||||
f.loopIn.DepositOutpoints,
|
||||
depositConfirmationHeights, currentHeight,
|
||||
) {
|
||||
|
||||
startPaymentDeadline(
|
||||
"legacy confirmation fallback",
|
||||
)
|
||||
}
|
||||
|
||||
// If the htlc is confirmed but blockChan fires before
|
||||
// htlcConfChan, we would wrongfully assume that the
|
||||
// htlc tx was not confirmed which would lead to
|
||||
|
|
@ -759,13 +866,13 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
if !htlcConfirmed {
|
||||
f.Infof("swap timed out, htlc not confirmed")
|
||||
|
||||
// If the htlc hasn't confirmed but the timeout
|
||||
// path opened up, and we didn't receive the
|
||||
// swap payment, we consider the swap attempt to
|
||||
// be failed. We cancelled the invoice, but
|
||||
// don't need to unlock the deposits because
|
||||
// that happened when the payment deadline was
|
||||
// reached.
|
||||
if !depositsUnlocked {
|
||||
err = f.unlockDeposits(ctx)
|
||||
if err != nil {
|
||||
return f.HandleError(err)
|
||||
}
|
||||
}
|
||||
|
||||
return OnSwapTimedOut
|
||||
}
|
||||
|
||||
|
|
@ -932,9 +1039,7 @@ func (f *FSM) PaymentReceivedAction(ctx context.Context,
|
|||
func (f *FSM) UnlockDepositsAction(ctx context.Context,
|
||||
_ fsm.EventContext) fsm.EventType {
|
||||
|
||||
err := f.cfg.DepositManager.TransitionDeposits(
|
||||
ctx, f.loopIn.Deposits, fsm.OnError, deposit.Deposited,
|
||||
)
|
||||
err := f.unlockDeposits(ctx)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("unable to unlock deposits: %w", err)
|
||||
|
||||
|
|
@ -944,6 +1049,13 @@ func (f *FSM) UnlockDepositsAction(ctx context.Context,
|
|||
return fsm.OnError
|
||||
}
|
||||
|
||||
// unlockDeposits resets this loop-in's deposits so they can be selected again.
|
||||
func (f *FSM) unlockDeposits(ctx context.Context) error {
|
||||
return f.cfg.DepositManager.TransitionDeposits(
|
||||
ctx, f.loopIn.Deposits, fsm.OnError, deposit.Deposited,
|
||||
)
|
||||
}
|
||||
|
||||
// createAndPublishHtlcTimeoutSweepTx creates and publishes the htlc timeout
|
||||
// sweep transaction.
|
||||
func (f *FSM) createAndPublishHtlcTimeoutSweepTx(ctx context.Context) error {
|
||||
|
|
|
|||
|
|
@ -270,6 +270,441 @@ func testValidateLoopInContract(_ int32, _ int32) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted verifies that the
|
||||
// payment timeout does not start until the server notifies us that confirmation
|
||||
// risk was accepted.
|
||||
func TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mockLnd := test.NewMockLnd()
|
||||
|
||||
clientKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
serverKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
swapHash := lntypes.Hash{4, 5, 6}
|
||||
depositOutpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{7},
|
||||
Index: 0,
|
||||
}
|
||||
|
||||
loopIn := &StaticAddressLoopIn{
|
||||
SwapHash: swapHash,
|
||||
HtlcCltvExpiry: 2_000,
|
||||
InitiationHeight: uint32(mockLnd.Height),
|
||||
InitiationTime: time.Now().Add(-time.Hour),
|
||||
ProtocolVersion: version.ProtocolVersion_V0,
|
||||
ClientPubkey: clientKey.PubKey(),
|
||||
ServerPubkey: serverKey.PubKey(),
|
||||
PaymentTimeoutSeconds: 1,
|
||||
DepositOutpoints: []string{
|
||||
depositOutpoint.String(),
|
||||
},
|
||||
Deposits: []*deposit.Deposit{{
|
||||
OutPoint: depositOutpoint,
|
||||
}},
|
||||
}
|
||||
loopIn.SetState(MonitorInvoiceAndHtlcTx)
|
||||
|
||||
mockLnd.SetInvoice(&lndclient.Invoice{
|
||||
Hash: swapHash,
|
||||
State: invoices.ContractOpen,
|
||||
})
|
||||
|
||||
notificationMgr := &mockNotificationManager{
|
||||
riskAccepted: make(
|
||||
chan *swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification, 1,
|
||||
),
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
AddressManager: &mockAddressManager{
|
||||
params: &script.Parameters{
|
||||
ClientPubkey: clientKey.PubKey(),
|
||||
ServerPubkey: serverKey.PubKey(),
|
||||
ProtocolVersion: version.ProtocolVersion_V0,
|
||||
},
|
||||
},
|
||||
ChainNotifier: mockLnd.ChainNotifier,
|
||||
DepositManager: &noopDepositManager{},
|
||||
InvoicesClient: mockLnd.LndServices.Invoices,
|
||||
LndClient: mockLnd.Client,
|
||||
ChainParams: mockLnd.ChainParams,
|
||||
NotificationManager: notificationMgr,
|
||||
}
|
||||
|
||||
f, err := NewFSM(ctx, loopIn, cfg, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
resultChan := make(chan fsm.EventType, 1)
|
||||
go func() {
|
||||
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
|
||||
}()
|
||||
|
||||
waitForMonitorSubscriptions(t, ctx, mockLnd)
|
||||
|
||||
select {
|
||||
case hash := <-mockLnd.FailInvoiceChannel:
|
||||
t.Fatalf("invoice canceled before risk acceptance: %v", hash)
|
||||
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
notificationMgr.riskAccepted <- &swapserverrpc.ServerStaticLoopInRiskAcceptedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
}
|
||||
|
||||
select {
|
||||
case hash := <-mockLnd.FailInvoiceChannel:
|
||||
t.Fatalf("invoice canceled immediately after risk acceptance: %v",
|
||||
hash)
|
||||
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
select {
|
||||
case hash := <-mockLnd.FailInvoiceChannel:
|
||||
require.Equal(t, swapHash, hash)
|
||||
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("invoice was not canceled: %v", ctx.Err())
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case event := <-resultChan:
|
||||
require.Equal(t, fsm.OnError, event)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("monitor action did not exit")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMonitorInvoiceAndHtlcTxStartsDeadlineAtLegacyMinConfs verifies that the
|
||||
// monitor action preserves the legacy payment deadline fallback when no risk
|
||||
// notification manager is available.
|
||||
func TestMonitorInvoiceAndHtlcTxStartsDeadlineAtLegacyMinConfs(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mockLnd := test.NewMockLnd()
|
||||
|
||||
clientKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
serverKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
swapHash := lntypes.Hash{7, 8, 9}
|
||||
depositOutpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{8},
|
||||
Index: 0,
|
||||
}
|
||||
depositRecord := &deposit.Deposit{
|
||||
OutPoint: depositOutpoint,
|
||||
}
|
||||
loopIn := &StaticAddressLoopIn{
|
||||
SwapHash: swapHash,
|
||||
HtlcCltvExpiry: 2_000,
|
||||
InitiationHeight: uint32(mockLnd.Height),
|
||||
InitiationTime: time.Now(),
|
||||
ProtocolVersion: version.ProtocolVersion_V0,
|
||||
ClientPubkey: clientKey.PubKey(),
|
||||
ServerPubkey: serverKey.PubKey(),
|
||||
PaymentTimeoutSeconds: 1,
|
||||
DepositOutpoints: []string{
|
||||
depositOutpoint.String(),
|
||||
},
|
||||
Deposits: []*deposit.Deposit{depositRecord},
|
||||
}
|
||||
loopIn.SetState(MonitorInvoiceAndHtlcTx)
|
||||
|
||||
mockLnd.SetInvoice(&lndclient.Invoice{
|
||||
Hash: swapHash,
|
||||
State: invoices.ContractOpen,
|
||||
})
|
||||
|
||||
cfg := &Config{
|
||||
AddressManager: &mockAddressManager{
|
||||
params: &script.Parameters{
|
||||
ClientPubkey: clientKey.PubKey(),
|
||||
ServerPubkey: serverKey.PubKey(),
|
||||
ProtocolVersion: version.ProtocolVersion_V0,
|
||||
},
|
||||
},
|
||||
ChainNotifier: mockLnd.ChainNotifier,
|
||||
DepositManager: &noopDepositManager{},
|
||||
InvoicesClient: mockLnd.LndServices.Invoices,
|
||||
LndClient: mockLnd.Client,
|
||||
ChainParams: mockLnd.ChainParams,
|
||||
}
|
||||
|
||||
f, err := NewFSM(ctx, loopIn, cfg, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
resultChan := make(chan fsm.EventType, 1)
|
||||
go func() {
|
||||
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
|
||||
}()
|
||||
|
||||
waitForMonitorSubscriptions(t, ctx, mockLnd)
|
||||
|
||||
select {
|
||||
case hash := <-mockLnd.FailInvoiceChannel:
|
||||
t.Fatalf("invoice canceled before deposit confirmation: %v", hash)
|
||||
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1
|
||||
depositRecord.Lock()
|
||||
depositRecord.ConfirmationHeight = confirmationHeight
|
||||
depositRecord.Unlock()
|
||||
|
||||
require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height))
|
||||
|
||||
select {
|
||||
case hash := <-mockLnd.FailInvoiceChannel:
|
||||
require.Equal(t, swapHash, hash)
|
||||
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("invoice was not canceled: %v", ctx.Err())
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case event := <-resultChan:
|
||||
require.Equal(t, fsm.OnError, event)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("monitor action did not exit")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackWithNotificationManager
|
||||
// verifies that old servers that do not send risk notifications still get the
|
||||
// legacy payment deadline even when the notification manager is configured.
|
||||
func TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackWithNotificationManager(
|
||||
t *testing.T) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mockLnd := test.NewMockLnd()
|
||||
|
||||
clientKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
serverKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
swapHash := lntypes.Hash{7, 8, 10}
|
||||
depositOutpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{9},
|
||||
Index: 0,
|
||||
}
|
||||
depositRecord := &deposit.Deposit{
|
||||
OutPoint: depositOutpoint,
|
||||
}
|
||||
loopIn := &StaticAddressLoopIn{
|
||||
SwapHash: swapHash,
|
||||
HtlcCltvExpiry: 2_000,
|
||||
InitiationHeight: uint32(mockLnd.Height),
|
||||
InitiationTime: time.Now(),
|
||||
ProtocolVersion: version.ProtocolVersion_V0,
|
||||
ClientPubkey: clientKey.PubKey(),
|
||||
ServerPubkey: serverKey.PubKey(),
|
||||
PaymentTimeoutSeconds: 1,
|
||||
DepositOutpoints: []string{
|
||||
depositOutpoint.String(),
|
||||
},
|
||||
Deposits: []*deposit.Deposit{depositRecord},
|
||||
}
|
||||
loopIn.SetState(MonitorInvoiceAndHtlcTx)
|
||||
|
||||
mockLnd.SetInvoice(&lndclient.Invoice{
|
||||
Hash: swapHash,
|
||||
State: invoices.ContractOpen,
|
||||
})
|
||||
|
||||
notificationMgr := &mockNotificationManager{
|
||||
riskAccepted: make(
|
||||
chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification,
|
||||
1,
|
||||
),
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
AddressManager: &mockAddressManager{
|
||||
params: &script.Parameters{
|
||||
ClientPubkey: clientKey.PubKey(),
|
||||
ServerPubkey: serverKey.PubKey(),
|
||||
ProtocolVersion: version.ProtocolVersion_V0,
|
||||
},
|
||||
},
|
||||
ChainNotifier: mockLnd.ChainNotifier,
|
||||
DepositManager: &noopDepositManager{},
|
||||
InvoicesClient: mockLnd.LndServices.Invoices,
|
||||
LndClient: mockLnd.Client,
|
||||
ChainParams: mockLnd.ChainParams,
|
||||
NotificationManager: notificationMgr,
|
||||
}
|
||||
|
||||
f, err := NewFSM(ctx, loopIn, cfg, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
resultChan := make(chan fsm.EventType, 1)
|
||||
go func() {
|
||||
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
|
||||
}()
|
||||
|
||||
waitForMonitorSubscriptions(t, ctx, mockLnd)
|
||||
|
||||
confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1
|
||||
depositRecord.Lock()
|
||||
depositRecord.ConfirmationHeight = confirmationHeight
|
||||
depositRecord.Unlock()
|
||||
|
||||
require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height))
|
||||
|
||||
select {
|
||||
case hash := <-mockLnd.FailInvoiceChannel:
|
||||
t.Fatalf("invoice canceled before payment deadline: %v", hash)
|
||||
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
select {
|
||||
case hash := <-mockLnd.FailInvoiceChannel:
|
||||
require.Equal(t, swapHash, hash)
|
||||
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("invoice was not canceled: %v", ctx.Err())
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case event := <-resultChan:
|
||||
require.Equal(t, fsm.OnError, event)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("monitor action did not exit")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMonitorInvoiceAndHtlcTxUnlocksOnHtlcTimeoutWithoutDeadline verifies that
|
||||
// deposits are unlocked even if the payment deadline never started before the
|
||||
// HTLC timeout path opened.
|
||||
func TestMonitorInvoiceAndHtlcTxUnlocksOnHtlcTimeoutWithoutDeadline(
|
||||
t *testing.T) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mockLnd := test.NewMockLnd()
|
||||
|
||||
clientKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
serverKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
swapHash := lntypes.Hash{10, 11, 12}
|
||||
depositOutpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{10},
|
||||
Index: 0,
|
||||
}
|
||||
|
||||
loopIn := &StaticAddressLoopIn{
|
||||
SwapHash: swapHash,
|
||||
HtlcCltvExpiry: mockLnd.Height,
|
||||
InitiationHeight: uint32(mockLnd.Height),
|
||||
InitiationTime: time.Now(),
|
||||
ProtocolVersion: version.ProtocolVersion_V0,
|
||||
ClientPubkey: clientKey.PubKey(),
|
||||
ServerPubkey: serverKey.PubKey(),
|
||||
PaymentTimeoutSeconds: 3_600,
|
||||
DepositOutpoints: []string{
|
||||
depositOutpoint.String(),
|
||||
},
|
||||
Deposits: []*deposit.Deposit{{
|
||||
OutPoint: depositOutpoint,
|
||||
}},
|
||||
}
|
||||
loopIn.SetState(MonitorInvoiceAndHtlcTx)
|
||||
|
||||
mockLnd.SetInvoice(&lndclient.Invoice{
|
||||
Hash: swapHash,
|
||||
State: invoices.ContractOpen,
|
||||
})
|
||||
|
||||
depositMgr := &recordingDepositManager{}
|
||||
cfg := &Config{
|
||||
AddressManager: &mockAddressManager{
|
||||
params: &script.Parameters{
|
||||
ClientPubkey: clientKey.PubKey(),
|
||||
ServerPubkey: serverKey.PubKey(),
|
||||
ProtocolVersion: version.ProtocolVersion_V0,
|
||||
},
|
||||
},
|
||||
ChainNotifier: mockLnd.ChainNotifier,
|
||||
DepositManager: depositMgr,
|
||||
InvoicesClient: mockLnd.LndServices.Invoices,
|
||||
LndClient: mockLnd.Client,
|
||||
ChainParams: mockLnd.ChainParams,
|
||||
}
|
||||
|
||||
f, err := NewFSM(ctx, loopIn, cfg, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
resultChan := make(chan fsm.EventType, 1)
|
||||
go func() {
|
||||
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
|
||||
}()
|
||||
|
||||
waitForMonitorSubscriptions(t, ctx, mockLnd)
|
||||
|
||||
require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height+1))
|
||||
|
||||
select {
|
||||
case hash := <-mockLnd.FailInvoiceChannel:
|
||||
require.Equal(t, swapHash, hash)
|
||||
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("invoice was not canceled: %v", ctx.Err())
|
||||
}
|
||||
|
||||
select {
|
||||
case event := <-resultChan:
|
||||
require.Equal(t, OnSwapTimedOut, event)
|
||||
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("monitor action did not exit: %v", ctx.Err())
|
||||
}
|
||||
|
||||
require.Equal(t, []fsm.EventType{fsm.OnError}, depositMgr.events)
|
||||
require.Equal(t, []fsm.StateType{deposit.Deposited}, depositMgr.states)
|
||||
}
|
||||
|
||||
// waitForMonitorSubscriptions waits until invoice and HTLC watchers are active.
|
||||
func waitForMonitorSubscriptions(t *testing.T, ctx context.Context,
|
||||
mockLnd *test.LndMockServices) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case <-mockLnd.SingleInvoiceSubcribeChannel:
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("invoice subscription not registered: %v", ctx.Err())
|
||||
}
|
||||
|
||||
select {
|
||||
case <-mockLnd.RegisterConfChannel:
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("htlc conf registration not received: %v", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// TestOriginalDepositOutpointUnavailableRequiresMissingTxOut verifies that a
|
||||
// present txout does not trigger the RBF cancellation path.
|
||||
func TestOriginalDepositOutpointUnavailableRequiresMissingTxOut(t *testing.T) {
|
||||
|
|
@ -573,6 +1008,45 @@ func (n *noopDepositManager) GetActiveDepositsInState(fsm.StateType) (
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
type recordingDepositManager struct {
|
||||
noopDepositManager
|
||||
|
||||
events []fsm.EventType
|
||||
states []fsm.StateType
|
||||
}
|
||||
|
||||
// TransitionDeposits records transition requests.
|
||||
func (r *recordingDepositManager) TransitionDeposits(_ context.Context,
|
||||
_ []*deposit.Deposit, event fsm.EventType,
|
||||
expectedFinalState fsm.StateType) error {
|
||||
|
||||
r.events = append(r.events, event)
|
||||
r.states = append(r.states, expectedFinalState)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mockNotificationManager allows tests to push server notifications directly to
|
||||
// monitor actions.
|
||||
type mockNotificationManager struct {
|
||||
riskAccepted chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification
|
||||
}
|
||||
|
||||
// SubscribeStaticLoopInSweepRequests implements NotificationManager.
|
||||
func (m *mockNotificationManager) SubscribeStaticLoopInSweepRequests(
|
||||
context.Context) <-chan *swapserverrpc.ServerStaticLoopInSweepNotification {
|
||||
|
||||
return make(chan *swapserverrpc.ServerStaticLoopInSweepNotification)
|
||||
}
|
||||
|
||||
// SubscribeStaticLoopInRiskAccepted implements NotificationManager.
|
||||
func (m *mockNotificationManager) SubscribeStaticLoopInRiskAccepted(
|
||||
context.Context, lntypes.Hash,
|
||||
) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification {
|
||||
|
||||
return m.riskAccepted
|
||||
}
|
||||
|
||||
type testTxOutChecker struct {
|
||||
txOut *wire.TxOut
|
||||
err error
|
||||
|
|
|
|||
|
|
@ -122,4 +122,11 @@ type NotificationManager interface {
|
|||
// a sweep of a static loop in that has been finished.
|
||||
SubscribeStaticLoopInSweepRequests(ctx context.Context,
|
||||
) <-chan *swapserverrpc.ServerStaticLoopInSweepNotification
|
||||
|
||||
// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk
|
||||
// accepted notifications. These are sent by the server after the selected
|
||||
// deposits are accepted by confirmation risk tracking.
|
||||
SubscribeStaticLoopInRiskAccepted(
|
||||
ctx context.Context, swapHash lntypes.Hash,
|
||||
) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification
|
||||
}
|
||||
|
|
|
|||
|
|
@ -467,12 +467,28 @@ func (l *StaticAddressLoopIn) TotalDepositAmount() btcutil.Amount {
|
|||
|
||||
// RemainingPaymentTimeSeconds returns the remaining time in seconds until the
|
||||
// payment timeout is reached. The remaining time is calculated from the
|
||||
// initiation time of the swap. If more than the swaps configured payment
|
||||
// initiation time of the swap. If more than the swap's configured payment
|
||||
// timeout has passed, the remaining time will be negative.
|
||||
func (l *StaticAddressLoopIn) RemainingPaymentTimeSeconds() int64 {
|
||||
elapsedSinceInitiation := time.Since(l.InitiationTime).Seconds()
|
||||
|
||||
return int64(l.PaymentTimeoutSeconds) - int64(elapsedSinceInitiation)
|
||||
return l.paymentTimeoutSeconds() - int64(elapsedSinceInitiation)
|
||||
}
|
||||
|
||||
// PaymentTimeoutDuration returns the configured payment timeout duration,
|
||||
// falling back to the default if the swap predates the persisted timeout field.
|
||||
func (l *StaticAddressLoopIn) PaymentTimeoutDuration() time.Duration {
|
||||
return time.Duration(l.paymentTimeoutSeconds()) * time.Second
|
||||
}
|
||||
|
||||
// paymentTimeoutSeconds returns the configured timeout in seconds.
|
||||
func (l *StaticAddressLoopIn) paymentTimeoutSeconds() int64 {
|
||||
timeoutSeconds := int64(l.PaymentTimeoutSeconds)
|
||||
if timeoutSeconds == 0 {
|
||||
timeoutSeconds = int64(DefaultPaymentTimeoutSeconds)
|
||||
}
|
||||
|
||||
return timeoutSeconds
|
||||
}
|
||||
|
||||
// Outpoints returns the wire outpoints of the deposits.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue