From b8cab8e08cb4ce2ada63093176b4ba35107ed7fb Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 27 Aug 2026 10:45:22 +0200 Subject: [PATCH 1/2] staticaddr/loopin: persist confirmed HTLC outputs Record the exact confirmed HTLC transaction, output index, and value so recovery sweeps the server-published output instead of reconstructing it from legacy assumptions. --- ...00024_static_loopin_htlc_outpoint.down.sql | 8 + .../000024_static_loopin_htlc_outpoint.up.sql | 8 + loopdb/sqlc/models.go | 3 + loopdb/sqlc/queries/static_address_loopin.sql | 5 +- loopdb/sqlc/static_address_loopin.sql.go | 39 ++- staticaddr/loopin/actions.go | 70 ++++- staticaddr/loopin/actions_test.go | 259 +++++++++++++++++- staticaddr/loopin/loopin.go | 90 ++++-- staticaddr/loopin/loopin_test.go | 71 +++++ staticaddr/loopin/sql_store.go | 36 ++- staticaddr/loopin/sql_store_test.go | 117 ++++++++ test/chainnotifier_mock.go | 6 + 12 files changed, 673 insertions(+), 39 deletions(-) create mode 100644 loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.down.sql create mode 100644 loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.up.sql diff --git a/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.down.sql b/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.down.sql new file mode 100644 index 000000000..caaf9571a --- /dev/null +++ b/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.down.sql @@ -0,0 +1,8 @@ +ALTER TABLE static_address_swaps + DROP COLUMN confirmed_htlc_output_value; + +ALTER TABLE static_address_swaps + DROP COLUMN confirmed_htlc_output_index; + +ALTER TABLE static_address_swaps + DROP COLUMN confirmed_htlc_tx_id; diff --git a/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.up.sql b/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.up.sql new file mode 100644 index 000000000..1af35c637 --- /dev/null +++ b/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE static_address_swaps + ADD confirmed_htlc_tx_id TEXT; + +ALTER TABLE static_address_swaps + ADD confirmed_htlc_output_index INTEGER; + +ALTER TABLE static_address_swaps + ADD confirmed_htlc_output_value BIGINT; diff --git a/loopdb/sqlc/models.go b/loopdb/sqlc/models.go index bb4244ea2..94e9cf1eb 100644 --- a/loopdb/sqlc/models.go +++ b/loopdb/sqlc/models.go @@ -153,6 +153,9 @@ type StaticAddressSwap struct { ConfirmationRiskDecision string ConfirmationRiskDecisionTime sql.NullTime ChangeStaticAddressID sql.NullInt32 + ConfirmedHtlcTxID sql.NullString + ConfirmedHtlcOutputIndex sql.NullInt32 + ConfirmedHtlcOutputValue sql.NullInt64 } type StaticAddressSwapUpdate struct { diff --git a/loopdb/sqlc/queries/static_address_loopin.sql b/loopdb/sqlc/queries/static_address_loopin.sql index e38b0b405..ce40bc577 100644 --- a/loopdb/sqlc/queries/static_address_loopin.sql +++ b/loopdb/sqlc/queries/static_address_loopin.sql @@ -31,7 +31,10 @@ INSERT INTO static_address_swaps ( UPDATE static_address_swaps SET htlc_tx_fee_rate_sat_kw = $2, - htlc_timeout_sweep_tx_id = $3 + htlc_timeout_sweep_tx_id = $3, + confirmed_htlc_tx_id = $4, + confirmed_htlc_output_index = $5, + confirmed_htlc_output_value = $6 WHERE swap_hash = $1; diff --git a/loopdb/sqlc/static_address_loopin.sql.go b/loopdb/sqlc/static_address_loopin.sql.go index 6c297f323..bc17bec74 100644 --- a/loopdb/sqlc/static_address_loopin.sql.go +++ b/loopdb/sqlc/static_address_loopin.sql.go @@ -180,7 +180,7 @@ func (q *Queries) GetLoopInSwapUpdates(ctx context.Context, swapHash []byte) ([] const getStaticAddressLoopInSwap = `-- name: GetStaticAddressLoopInSwap :one SELECT swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label, - static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time, static_address_swaps.change_static_address_id, + static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time, static_address_swaps.change_static_address_id, static_address_swaps.confirmed_htlc_tx_id, static_address_swaps.confirmed_htlc_output_index, static_address_swaps.confirmed_htlc_output_value, htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index, change_address.client_pubkey change_client_pubkey, change_address.server_pubkey change_server_pubkey, @@ -230,6 +230,9 @@ type GetStaticAddressLoopInSwapRow struct { ConfirmationRiskDecision string ConfirmationRiskDecisionTime sql.NullTime ChangeStaticAddressID sql.NullInt32 + ConfirmedHtlcTxID sql.NullString + ConfirmedHtlcOutputIndex sql.NullInt32 + ConfirmedHtlcOutputValue sql.NullInt64 SwapHash_3 []byte SenderScriptPubkey []byte ReceiverScriptPubkey []byte @@ -277,6 +280,9 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt &i.ConfirmationRiskDecision, &i.ConfirmationRiskDecisionTime, &i.ChangeStaticAddressID, + &i.ConfirmedHtlcTxID, + &i.ConfirmedHtlcOutputIndex, + &i.ConfirmedHtlcOutputValue, &i.SwapHash_3, &i.SenderScriptPubkey, &i.ReceiverScriptPubkey, @@ -299,7 +305,7 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt const getStaticAddressLoopInSwapsByStates = `-- name: GetStaticAddressLoopInSwapsByStates :many SELECT swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label, - static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time, static_address_swaps.change_static_address_id, + static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time, static_address_swaps.change_static_address_id, static_address_swaps.confirmed_htlc_tx_id, static_address_swaps.confirmed_htlc_output_index, static_address_swaps.confirmed_htlc_output_value, htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index, change_address.client_pubkey change_client_pubkey, change_address.server_pubkey change_server_pubkey, @@ -360,6 +366,9 @@ type GetStaticAddressLoopInSwapsByStatesRow struct { ConfirmationRiskDecision string ConfirmationRiskDecisionTime sql.NullTime ChangeStaticAddressID sql.NullInt32 + ConfirmedHtlcTxID sql.NullString + ConfirmedHtlcOutputIndex sql.NullInt32 + ConfirmedHtlcOutputValue sql.NullInt64 SwapHash_3 []byte SenderScriptPubkey []byte ReceiverScriptPubkey []byte @@ -413,6 +422,9 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla &i.ConfirmationRiskDecision, &i.ConfirmationRiskDecisionTime, &i.ChangeStaticAddressID, + &i.ConfirmedHtlcTxID, + &i.ConfirmedHtlcOutputIndex, + &i.ConfirmedHtlcOutputValue, &i.SwapHash_3, &i.SenderScriptPubkey, &i.ReceiverScriptPubkey, @@ -627,18 +639,31 @@ const updateStaticAddressLoopIn = `-- name: UpdateStaticAddressLoopIn :exec UPDATE static_address_swaps SET htlc_tx_fee_rate_sat_kw = $2, - htlc_timeout_sweep_tx_id = $3 + htlc_timeout_sweep_tx_id = $3, + confirmed_htlc_tx_id = $4, + confirmed_htlc_output_index = $5, + confirmed_htlc_output_value = $6 WHERE swap_hash = $1 ` type UpdateStaticAddressLoopInParams struct { - SwapHash []byte - HtlcTxFeeRateSatKw int64 - HtlcTimeoutSweepTxID sql.NullString + SwapHash []byte + HtlcTxFeeRateSatKw int64 + HtlcTimeoutSweepTxID sql.NullString + ConfirmedHtlcTxID sql.NullString + ConfirmedHtlcOutputIndex sql.NullInt32 + ConfirmedHtlcOutputValue sql.NullInt64 } func (q *Queries) UpdateStaticAddressLoopIn(ctx context.Context, arg UpdateStaticAddressLoopInParams) error { - _, err := q.db.ExecContext(ctx, updateStaticAddressLoopIn, arg.SwapHash, arg.HtlcTxFeeRateSatKw, arg.HtlcTimeoutSweepTxID) + _, err := q.db.ExecContext(ctx, updateStaticAddressLoopIn, + arg.SwapHash, + arg.HtlcTxFeeRateSatKw, + arg.HtlcTimeoutSweepTxID, + arg.ConfirmedHtlcTxID, + arg.ConfirmedHtlcOutputIndex, + arg.ConfirmedHtlcOutputValue, + ) return err } diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 3e518333c..ee7992ccd 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -1,6 +1,7 @@ package loopin import ( + "bytes" "context" "crypto/rand" "errors" @@ -1151,9 +1152,14 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, htlcConfirmed := false for { select { - case <-htlcConfChan: + case conf := <-htlcConfChan: f.Infof("htlc tx confirmed") + err = f.recordConfirmedHtlc(ctx, conf, htlc.PkScript) + if err != nil { + return f.HandleError(err) + } + htlcConfirmed = true if invoiceCanceledForNonPayment { err = transitionDepositsToHtlcTimeout( @@ -1194,6 +1200,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, // confirmation and re-register for the next // confirmation. htlcConfirmed = false + err = f.clearConfirmedHtlc(ctx) + if err != nil { + return f.HandleError(err) + } htlcConfChan, htlcErrConfChan, err = registerHtlcConf() if err != nil { @@ -1371,6 +1381,64 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, } } +func (f *FSM) recordConfirmedHtlc(ctx context.Context, + conf *chainntnfs.TxConfirmation, htlcPkScript []byte) error { + + if conf == nil || conf.Tx == nil { + return errors.New("htlc confirmation missing transaction") + } + if f.cfg.Store == nil { + return errors.New("missing static address loop-in store") + } + + tx := conf.Tx + txHash := tx.TxHash() + expectedInputs := f.loopIn.Outpoints() + if len(tx.TxIn) != len(expectedInputs) { + return fmt.Errorf("confirmed htlc tx %v has %d inputs, expected "+ + "%d", txHash, len(tx.TxIn), len(expectedInputs)) + } + for idx, expectedInput := range expectedInputs { + if tx.TxIn[idx].PreviousOutPoint != expectedInput { + return fmt.Errorf("confirmed htlc tx %v input %d spends %v, "+ + "expected %v", txHash, idx, + tx.TxIn[idx].PreviousOutPoint, expectedInput) + } + } + + for idx, txOut := range tx.TxOut { + if !bytes.Equal(txOut.PkScript, htlcPkScript) { + continue + } + + f.loopIn.HtlcTxHash = &txHash + f.loopIn.HtlcOutputIndex = uint32(idx) + f.loopIn.HtlcOutputValue = btcutil.Amount(txOut.Value) + + return f.cfg.Store.UpdateLoopIn(ctx, f.loopIn) + } + + return fmt.Errorf("confirmed htlc tx %v missing expected htlc "+ + "output", txHash) +} + +func (f *FSM) clearConfirmedHtlc(ctx context.Context) error { + if f.loopIn.HtlcTxHash == nil && f.loopIn.HtlcOutputIndex == 0 && + f.loopIn.HtlcOutputValue == 0 { + + return nil + } + if f.cfg.Store == nil { + return errors.New("missing static address loop-in store") + } + + f.loopIn.HtlcTxHash = nil + f.loopIn.HtlcOutputIndex = 0 + f.loopIn.HtlcOutputValue = 0 + + return f.cfg.Store.UpdateLoopIn(ctx, f.loopIn) +} + // htlcTimeoutSweepRetryDelay is the delay between retries when publishing the // htlc timeout sweep transaction fails. const htlcTimeoutSweepRetryDelay = time.Hour diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 3844d351a..3b6bf6b03 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -20,6 +20,7 @@ import ( "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/invoices" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/zpay32" @@ -1187,6 +1188,220 @@ func testStaticAddressLoopInResponse( } } +type recordingLoopInStore struct { + mockStore + + updates []recordedLoopInUpdate + updateChan chan recordedLoopInUpdate +} + +type recordedLoopInUpdate struct { + htlcTxHash *chainhash.Hash + htlcOutputIndex uint32 + htlcOutputValue int64 +} + +func (s *recordingLoopInStore) UpdateLoopIn(_ context.Context, + loopIn *StaticAddressLoopIn) error { + + var htlcTxHash *chainhash.Hash + if loopIn.HtlcTxHash != nil { + hashCopy := *loopIn.HtlcTxHash + htlcTxHash = &hashCopy + } + + update := recordedLoopInUpdate{ + htlcTxHash: htlcTxHash, + htlcOutputIndex: loopIn.HtlcOutputIndex, + htlcOutputValue: int64(loopIn.HtlcOutputValue), + } + s.updates = append(s.updates, update) + if s.updateChan != nil { + s.updateChan <- update + } + + return nil +} + +// TestRecordConfirmedHtlcPersistsOutpoint verifies that the FSM records the +// exact confirmed server HTLC output before the timeout branch can sweep it. +func TestRecordConfirmedHtlcPersistsOutpoint(t *testing.T) { + t.Parallel() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + loopIn := &StaticAddressLoopIn{ + SwapHash: lntypes.Hash{1, 2, 4}, + HtlcCltvExpiry: 800, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + Deposits: []*deposit.Deposit{ + { + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{9}, + Index: 2, + }, + }, + { + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{10}, + Index: 3, + }, + }, + }, + } + htlc, err := loopIn.getHtlc(test.NewMockLnd().ChainParams) + require.NoError(t, err) + + htlcValue := int64(123_456) + tx := wire.NewMsgTx(2) + for _, outpoint := range loopIn.Outpoints() { + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: outpoint}) + } + tx.AddTxOut(&wire.TxOut{ + Value: 1, + PkScript: []byte{0x51}, + }) + tx.AddTxOut(&wire.TxOut{ + Value: htlcValue, + PkScript: htlc.PkScript, + }) + + store := &recordingLoopInStore{} + f := &FSM{ + cfg: &Config{Store: store}, + loopIn: loopIn, + } + invalidTransactions := map[string]*wire.MsgTx{ + "missing input": func() *wire.MsgTx { + decoy := tx.Copy() + decoy.TxIn = decoy.TxIn[:1] + + return decoy + }(), + "wrong input": func() *wire.MsgTx { + decoy := tx.Copy() + decoy.TxIn[0].PreviousOutPoint.Hash[0]++ + + return decoy + }(), + "reordered inputs": func() *wire.MsgTx { + decoy := tx.Copy() + decoy.TxIn[0], decoy.TxIn[1] = + decoy.TxIn[1], decoy.TxIn[0] + + return decoy + }(), + } + for name, decoy := range invalidTransactions { + t.Run(name, func(t *testing.T) { + err := f.recordConfirmedHtlc( + t.Context(), + &chainntnfs.TxConfirmation{Tx: decoy}, + htlc.PkScript, + ) + require.Error(t, err) + require.Nil(t, loopIn.HtlcTxHash) + require.Empty(t, store.updates) + }) + } + + err = f.recordConfirmedHtlc( + t.Context(), &chainntnfs.TxConfirmation{Tx: tx}, + htlc.PkScript, + ) + require.NoError(t, err) + + txHash := tx.TxHash() + require.NotNil(t, loopIn.HtlcTxHash) + require.Equal(t, txHash, *loopIn.HtlcTxHash) + require.EqualValues(t, 1, loopIn.HtlcOutputIndex) + require.EqualValues(t, htlcValue, loopIn.HtlcOutputValue) + require.Len(t, store.updates, 1) +} + +// TestMonitorInvoiceAndHtlcTxClearsConfirmedHtlcOnReorg verifies that a reorg +// removes the persisted HTLC outpoint before the FSM resumes confirmation +// monitoring. +func TestMonitorInvoiceAndHtlcTxClearsConfirmedHtlcOnReorg(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + mockLnd := test.NewMockLnd() + swapHash := lntypes.Hash{21, 22, 23} + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + f, _ := newInvoiceMonitorTestFSM( + t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionAccepted, + mockLnd.LndServices.Invoices, + ) + store := f.cfg.Store.(*recordingLoopInStore) + store.updateChan = make(chan recordedLoopInUpdate, 2) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + select { + case <-mockLnd.SingleInvoiceSubcribeChannel: + case <-ctx.Done(): + t.Fatalf("invoice subscription not registered: %v", ctx.Err()) + } + + var confRegistration *test.ConfRegistration + select { + case confRegistration = <-mockLnd.RegisterConfChannel: + case <-ctx.Done(): + t.Fatalf("htlc conf registration not received: %v", ctx.Err()) + } + require.NotNil(t, confRegistration.ReOrgChan) + confRegistration.ConfChan <- invoiceMonitorHtlcConfirmation( + t, f, mockLnd, + ) + + var confirmed recordedLoopInUpdate + select { + case confirmed = <-store.updateChan: + case <-ctx.Done(): + t.Fatalf("confirmed htlc not persisted: %v", ctx.Err()) + } + require.NotNil(t, confirmed.htlcTxHash) + require.Positive(t, confirmed.htlcOutputValue) + + confRegistration.ReOrgChan <- struct{}{} + + var cleared recordedLoopInUpdate + select { + case cleared = <-store.updateChan: + case <-ctx.Done(): + t.Fatalf("reorged htlc not cleared: %v", ctx.Err()) + } + require.Nil(t, cleared.htlcTxHash) + require.Zero(t, cleared.htlcOutputIndex) + require.Zero(t, cleared.htlcOutputValue) + + select { + case <-mockLnd.RegisterConfChannel: + case <-ctx.Done(): + t.Fatalf("htlc confirmation not re-registered: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + case <-time.After(testTimeout): + t.Fatal("monitor action did not stop") + } +} + // testStaticAddressRouteHints returns deterministic route hints for static // loop-in invoice regression tests. func testStaticAddressRouteHints() [][]zpay32.HopHint { @@ -1295,10 +1510,21 @@ func TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline(t *testing.T) { LndClient: mockLnd.Client, ChainParams: mockLnd.ChainParams, NotificationManager: notificationMgr, + Store: &recordingLoopInStore{}, } f, err := NewFSM(ctx, loopIn, cfg, false) require.NoError(t, err) + htlc, err := loopIn.getHtlc(mockLnd.ChainParams) + require.NoError(t, err) + htlcTx := wire.NewMsgTx(2) + for _, outpoint := range f.loopIn.Outpoints() { + htlcTx.AddTxIn(&wire.TxIn{PreviousOutPoint: outpoint}) + } + htlcTx.AddTxOut(&wire.TxOut{ + Value: 1, + PkScript: htlc.PkScript, + }) resultChan := make(chan fsm.EventType, 1) go func() { @@ -1317,7 +1543,7 @@ func TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline(t *testing.T) { case <-ctx.Done(): t.Fatalf("htlc conf registration not received: %v", ctx.Err()) } - confRegistration.ConfChan <- nil + confRegistration.ConfChan <- &chainntnfs.TxConfirmation{Tx: htlcTx} select { case hash := <-mockLnd.FailInvoiceChannel: @@ -2884,7 +3110,9 @@ func TestMonitorInvoiceAndHtlcTxDoesNotAdvanceWhenTimeoutDepositTransitionFails( t.Fatalf("htlc conf registration not received: %v", ctx.Err()) } - confRegistration.ConfChan <- nil + confRegistration.ConfChan <- invoiceMonitorHtlcConfirmation( + t, f, mockLnd, + ) select { case transition := <-depositMgr.transitionChan: @@ -2971,7 +3199,9 @@ func TestMonitorInvoiceAndHtlcTxRetriesOnlyPendingTimeoutDeposits(t *testing.T) t.Fatalf("htlc conf registration not received: %v", runCtx.Err()) } - confRegistration.ConfChan <- nil + confRegistration.ConfChan <- invoiceMonitorHtlcConfirmation( + t, f, mockLnd, + ) return resultChan } @@ -3128,6 +3358,7 @@ func newInvoiceMonitorTestFSM(t *testing.T, ctx context.Context, InvoicesClient: invoicesClient, LndClient: mockLnd.Client, ChainParams: mockLnd.ChainParams, + Store: &recordingLoopInStore{}, } f, err := NewFSM(ctx, loopIn, cfg, true) @@ -3136,6 +3367,28 @@ func newInvoiceMonitorTestFSM(t *testing.T, ctx context.Context, return f, depositMgr } +// invoiceMonitorHtlcConfirmation returns a confirmation containing the HTLC +// output expected by the invoice monitor. +func invoiceMonitorHtlcConfirmation(t *testing.T, f *FSM, + mockLnd *test.LndMockServices) *chainntnfs.TxConfirmation { + + t.Helper() + + htlc, err := f.loopIn.getHtlc(mockLnd.ChainParams) + require.NoError(t, err) + + htlcTx := wire.NewMsgTx(2) + for _, outpoint := range f.loopIn.Outpoints() { + htlcTx.AddTxIn(&wire.TxIn{PreviousOutPoint: outpoint}) + } + htlcTx.AddTxOut(&wire.TxOut{ + Value: int64(f.loopIn.TotalDepositAmount()), + PkScript: htlc.PkScript, + }) + + return &chainntnfs.TxConfirmation{Tx: htlcTx} +} + // failingCancelInvoices records cancellation attempts and returns a configured // error after its release channel is closed. type failingCancelInvoices struct { diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index 0203c3580..e6c1adc8b 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -179,6 +179,16 @@ type StaticAddressLoopIn struct { // HtlcTimeoutSweepTxHash is the hash of the htlc timeout sweep tx. HtlcTimeoutSweepTxHash *chainhash.Hash + // HtlcTxHash is the hash of the confirmed htlc tx published by the + // server. + HtlcTxHash *chainhash.Hash + + // HtlcOutputIndex is the output index of the confirmed htlc output. + HtlcOutputIndex uint32 + + // HtlcOutputValue is the value of the confirmed htlc output. + HtlcOutputValue btcutil.Amount + // HtlcTimeoutSweepAddress HtlcTimeoutSweepAddress btcutil.Address @@ -409,37 +419,18 @@ func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context, return nil, err } - htlcTx, err := l.createHtlcTx( - network, l.HtlcTxFeeRate, maxFeePercentage, + htlcOutpoint, htlcOutValue, err := l.confirmedHtlcOutpoint( + network, maxFeePercentage, ) if err != nil { return nil, err } - // The HTLC output is always at index 0 (createHtlcTx adds it first). - // If there is a change output, it is at index 1. Verify this invariant - // so we fail fast if createHtlcTx's layout ever changes. - const htlcInputIndex = uint32(0) - if len(htlcTx.TxOut) == 2 && l.ChangeAddressParams != nil { - if bytes.Equal( - htlcTx.TxOut[0].PkScript, - l.ChangeAddressParams.PkScript, - ) { - - return nil, fmt.Errorf("htlc tx output layout " + - "invariant violated: expected HTLC output " + - "at index 0, got change output") - } - } - // Add the htlc input. sweepTx.AddTxIn(&wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Hash: htlcTx.TxHash(), - Index: htlcInputIndex, - }, - SignatureScript: htlc.SigScript, - Sequence: htlc.SuccessSequence(), + PreviousOutPoint: htlcOutpoint, + SignatureScript: htlc.SigScript, + Sequence: htlc.SuccessSequence(), }) // Add the sweep output. @@ -450,7 +441,6 @@ func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context, fee := feeRate.FeeForWeight(weightEstimator.Weight()) - htlcOutValue := htlcTx.TxOut[htlcInputIndex].Value output := &wire.TxOut{ Value: htlcOutValue - int64(fee), PkScript: sweepPkScript, @@ -489,6 +479,56 @@ func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context, return sweepTx, nil } +// confirmedHtlcOutpoint returns the exact confirmed htlc outpoint when it has +// been persisted. Older loop-ins fall back to reconstructing the standard-fee +// htlc tx, which was the historical behavior before we stored the actual +// server-published variant. +func (l *StaticAddressLoopIn) confirmedHtlcOutpoint( + network *chaincfg.Params, maxFeePercentage float64) (wire.OutPoint, + int64, error) { + + if l.HtlcTxHash != nil { + if l.HtlcOutputValue <= 0 { + return wire.OutPoint{}, 0, fmt.Errorf("missing htlc "+ + "output value for confirmed htlc tx %v", + l.HtlcTxHash) + } + + return wire.OutPoint{ + Hash: *l.HtlcTxHash, + Index: l.HtlcOutputIndex, + }, int64(l.HtlcOutputValue), nil + } + + htlcTx, err := l.createHtlcTx( + network, l.HtlcTxFeeRate, maxFeePercentage, + ) + if err != nil { + return wire.OutPoint{}, 0, err + } + + // The HTLC output is always at index 0 (createHtlcTx adds it first). + // If there is a change output, it is at index 1. Verify this invariant + // so we fail fast if createHtlcTx's layout ever changes. + const htlcInputIndex = uint32(0) + if len(htlcTx.TxOut) == 2 && l.ChangeAddressParams != nil { + if bytes.Equal( + htlcTx.TxOut[0].PkScript, + l.ChangeAddressParams.PkScript, + ) { + + return wire.OutPoint{}, 0, fmt.Errorf("htlc tx " + + "output layout invariant violated: expected " + + "HTLC output at index 0, got change output") + } + } + + return wire.OutPoint{ + Hash: htlcTx.TxHash(), + Index: htlcInputIndex, + }, htlcTx.TxOut[htlcInputIndex].Value, nil +} + // pubkeyTo33ByteSlice converts a pubkey to a 33 byte slice. func pubkeyTo33ByteSlice(pubkey *btcec.PublicKey) [33]byte { var pubkeyBytes [33]byte diff --git a/staticaddr/loopin/loopin_test.go b/staticaddr/loopin/loopin_test.go index 7af946037..574c91d75 100644 --- a/staticaddr/loopin/loopin_test.go +++ b/staticaddr/loopin/loopin_test.go @@ -12,6 +12,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/version" @@ -184,6 +185,76 @@ func TestPaymentTimeoutDuration(t *testing.T) { } } +// TestCreateHtlcSweepTxUsesConfirmedHtlcOutpoint verifies that timeout sweeps +// spend the actual server-published HTLC tx variant once it has been recorded. +func TestCreateHtlcSweepTxUsesConfirmedHtlcOutpoint(t *testing.T) { + t.Parallel() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + network := &chaincfg.RegressionNetParams + staticAddr, err := newStaticAddress( + clientKey.PubKey(), serverKey.PubKey(), 4032, + ) + require.NoError(t, err) + + pkScript, err := staticAddr.StaticAddressScript() + require.NoError(t, err) + + addrParams := &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PkScript: pkScript, + Expiry: 4032, + ProtocolVersion: version.ProtocolVersion_V0, + } + + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0xbb}, + Index: 0, + }, + Value: 500_000, + AddressParams: addrParams, + } + + confirmedHtlcHash := chainhash.Hash{0xcc} + confirmedHtlcValue := btcutil.Amount(275_000) + loopIn := &StaticAddressLoopIn{ + SwapHash: lntypes.Hash{3, 2, 1}, + HtlcCltvExpiry: 800, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + Deposits: []*deposit.Deposit{dep}, + HtlcTxFeeRate: chainfee.SatPerKWeight(253), + HtlcTxHash: &confirmedHtlcHash, + HtlcOutputIndex: 2, + HtlcOutputValue: confirmedHtlcValue, + } + + sweepAddr, err := btcutil.NewAddressTaproot(make([]byte, 32), network) + require.NoError(t, err) + + sweepTx, err := loopIn.createHtlcSweepTx( + t.Context(), &noopSigner{}, sweepAddr, + chainfee.SatPerKWeight(253), network, + uint32(loopIn.HtlcCltvExpiry)+1, 1, + ) + require.NoError(t, err) + require.Len(t, sweepTx.TxIn, 1) + require.Equal( + t, wire.OutPoint{ + Hash: confirmedHtlcHash, + Index: 2, + }, sweepTx.TxIn[0].PreviousOutPoint, + ) + require.Less(t, sweepTx.TxOut[0].Value, int64(confirmedHtlcValue)) + require.Greater(t, sweepTx.TxOut[0].Value, int64(0)) +} + // newStaticAddress creates a StaticAddress for testing. func newStaticAddress(clientKey, serverKey *btcec.PublicKey, csvExpiry int64) (*script.StaticAddress, error) { diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index 713f2dd34..b8fa1ee62 100644 --- a/staticaddr/loopin/sql_store.go +++ b/staticaddr/loopin/sql_store.go @@ -369,6 +369,11 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context, htlcTimeoutSweepTxID = loopIn.HtlcTimeoutSweepTxHash.String() } + var htlcTxID string + if loopIn.HtlcTxHash != nil { + htlcTxID = loopIn.HtlcTxHash.String() + } + updateParams := sqlc.UpdateStaticAddressLoopInParams{ SwapHash: loopIn.SwapHash[:], HtlcTxFeeRateSatKw: int64(loopIn.HtlcTxFeeRate), @@ -376,6 +381,18 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context, String: htlcTimeoutSweepTxID, Valid: htlcTimeoutSweepTxID != "", }, + ConfirmedHtlcTxID: sql.NullString{ + String: htlcTxID, + Valid: htlcTxID != "", + }, + ConfirmedHtlcOutputIndex: sql.NullInt32{ + Int32: int32(loopIn.HtlcOutputIndex), + Valid: htlcTxID != "", + }, + ConfirmedHtlcOutputValue: sql.NullInt64{ + Int64: int64(loopIn.HtlcOutputValue), + Valid: htlcTxID != "", + }, } updateTime := sqlStoreUpdateTime(s.clock) @@ -587,6 +604,16 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, } } + var htlcTxHash *chainhash.Hash + if swap.ConfirmedHtlcTxID.Valid { + htlcTxHash, err = chainhash.NewHashFromStr( + swap.ConfirmedHtlcTxID.String, + ) + if err != nil { + return nil, err + } + } + var depositOutpoints []string if swap.DepositOutpoints != "" { depositOutpoints = strings.Split( @@ -687,8 +714,13 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, ), HtlcTimeoutSweepAddress: timeoutAddress, HtlcTimeoutSweepTxHash: htlcTimeoutSweepTxHash, - Deposits: depositList, - ChangeAddressParams: changeAddressParams, + HtlcTxHash: htlcTxHash, + HtlcOutputIndex: uint32(swap.ConfirmedHtlcOutputIndex.Int32), + HtlcOutputValue: btcutil.Amount( + swap.ConfirmedHtlcOutputValue.Int64, + ), + Deposits: depositList, + ChangeAddressParams: changeAddressParams, } if swap.ConfirmationRiskDecisionTime.Valid { loopIn.ConfirmationRiskDecisionTime = diff --git a/staticaddr/loopin/sql_store_test.go b/staticaddr/loopin/sql_store_test.go index 730048dc3..8a334840a 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -2,6 +2,7 @@ package loopin import ( "context" + "database/sql" "testing" "time" @@ -766,6 +767,122 @@ func TestGetLoopInByHashOrdersDepositsBySnapshot(t *testing.T) { require.Equal(t, d1.ID, storedSwap.Deposits[1].ID) } +func TestUpdateLoopInPersistsConfirmedHtlcOutpoint(t *testing.T) { + ctxb := context.Background() + testDb := loopdb.NewTestDB(t) + testClock := clock.NewTestClock(time.Now()) + defer testDb.Close() + + depositStore := deposit.NewSqlStore(testDb.BaseDB) + swapStore := NewSqlStore( + loopdb.NewTypedStore[Querier](testDb), testClock, + &chaincfg.RegressionNetParams, + ) + + depositID, err := deposit.GetRandomDepositID() + require.NoError(t, err) + + d := &deposit.Deposit{ + ID: depositID, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0x1a, 0x2b, 0x3c, 0x4d}, + Index: 0, + }, + Value: btcutil.Amount(100_000), + TimeOutSweepPkScript: []byte{ + 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x41, + }, + } + setPersistedTestDepositAddress(t, ctxb, testDb.BaseDB, d) + require.NoError(t, depositStore.CreateDeposit(ctxb, d)) + + d.SetState(deposit.LoopingIn) + require.NoError(t, depositStore.UpdateDeposit(ctxb, d)) + + _, clientPubKey := test.CreateKey(1) + _, serverPubKey := test.CreateKey(2) + addr, err := btcutil.DecodeAddress(P2wkhAddr, nil) + require.NoError(t, err) + + swapHash := lntypes.Hash{0x4, 0x2, 0x3, 0x5} + swap := StaticAddressLoopIn{ + SwapHash: swapHash, + SwapPreimage: lntypes.Preimage{0x4, 0x2, 0x3, 0x5}, + DepositOutpoints: []string{d.OutPoint.String()}, + Deposits: []*deposit.Deposit{d}, + ClientPubkey: clientPubKey, + ServerPubkey: serverPubKey, + HtlcTimeoutSweepAddress: addr, + } + swap.SetState(MonitorInvoiceAndHtlcTx) + require.NoError(t, swapStore.CreateLoopIn(ctxb, &swap)) + + confirmedHtlcTxHash := chainhash.Hash{0x55} + swap.HtlcTxHash = &confirmedHtlcTxHash + swap.HtlcOutputIndex = 2 + swap.HtlcOutputValue = 88_000 + testClock.SetTime(testClock.Now().Add(time.Second)) + require.NoError(t, swapStore.UpdateLoopIn(ctxb, &swap)) + + storedSwap, err := swapStore.GetLoopInByHash(ctxb, swapHash) + require.NoError(t, err) + require.NotNil(t, storedSwap.HtlcTxHash) + require.Equal(t, confirmedHtlcTxHash, *storedSwap.HtlcTxHash) + require.EqualValues(t, 2, storedSwap.HtlcOutputIndex) + require.EqualValues(t, 88_000, storedSwap.HtlcOutputValue) + require.Equal(t, MonitorInvoiceAndHtlcTx, storedSwap.GetState()) + + recoveredSwaps, err := swapStore.GetStaticAddressLoopInSwapsByStates( + ctxb, []fsm.StateType{MonitorInvoiceAndHtlcTx}, + ) + require.NoError(t, err) + require.Len(t, recoveredSwaps, 1) + recoveredSwap := recoveredSwaps[0] + require.NotNil(t, recoveredSwap.HtlcTxHash) + require.Equal(t, confirmedHtlcTxHash, *recoveredSwap.HtlcTxHash) + require.EqualValues(t, 2, recoveredSwap.HtlcOutputIndex) + require.EqualValues(t, 88_000, recoveredSwap.HtlcOutputValue) + + // A reorg clears the in-memory outpoint before UpdateLoopIn persists + // the invalidated confirmation. + swap.HtlcTxHash = nil + swap.HtlcOutputIndex = 0 + swap.HtlcOutputValue = 0 + testClock.SetTime(testClock.Now().Add(time.Second)) + require.NoError(t, swapStore.UpdateLoopIn(ctxb, &swap)) + + storedSwap, err = swapStore.GetLoopInByHash(ctxb, swapHash) + require.NoError(t, err) + require.Nil(t, storedSwap.HtlcTxHash) + require.Zero(t, storedSwap.HtlcOutputIndex) + require.Zero(t, storedSwap.HtlcOutputValue) + + recoveredSwaps, err = swapStore.GetStaticAddressLoopInSwapsByStates( + ctxb, []fsm.StateType{MonitorInvoiceAndHtlcTx}, + ) + require.NoError(t, err) + require.Len(t, recoveredSwaps, 1) + require.Nil(t, recoveredSwaps[0].HtlcTxHash) + require.Zero(t, recoveredSwaps[0].HtlcOutputIndex) + require.Zero(t, recoveredSwaps[0].HtlcOutputValue) + + var ( + txID sql.NullString + outputIndex sql.NullInt64 + outputValue sql.NullInt64 + ) + err = testDb.QueryRowContext(ctxb, ` + SELECT confirmed_htlc_tx_id, confirmed_htlc_output_index, + confirmed_htlc_output_value + FROM static_address_swaps + WHERE swap_hash = $1 + `, swapHash[:]).Scan(&txID, &outputIndex, &outputValue) + require.NoError(t, err) + require.False(t, txID.Valid) + require.False(t, outputIndex.Valid) + require.False(t, outputValue.Valid) +} + // TestGetLoopInByHashPreservesStoredDepositOutpoints ensures recovered loop-ins // keep the original outpoint snapshot stored when the swap was created. func TestGetLoopInByHashPreservesStoredDepositOutpoints(t *testing.T) { diff --git a/test/chainnotifier_mock.go b/test/chainnotifier_mock.go index 1da5f87d4..34ee87e22 100644 --- a/test/chainnotifier_mock.go +++ b/test/chainnotifier_mock.go @@ -48,6 +48,7 @@ type ConfRegistration struct { NumConfs int32 ConfChan chan *chainntnfs.TxConfirmation ErrChan chan<- error + ReOrgChan chan struct{} } func (c *mockChainNotifier) RegisterSpendNtfn(ctx context.Context, @@ -154,6 +155,10 @@ func (c *mockChainNotifier) RegisterConfirmationsNtfn(ctx context.Context, chan error, error) { confErrChan := make(chan error, 1) + notifierOpts := lndclient.DefaultNotifierOptions() + for _, opt := range opts { + opt(notifierOpts) + } reg := &ConfRegistration{ PkScript: pkScript, @@ -162,6 +167,7 @@ func (c *mockChainNotifier) RegisterConfirmationsNtfn(ctx context.Context, NumConfs: numConfs, ConfChan: make(chan *chainntnfs.TxConfirmation, 1), ErrChan: confErrChan, + ReOrgChan: notifierOpts.ReOrgChan, } c.Lock() From fe5280c6d89258780fcf9bf0b65382f3fa99fd3f Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 28 Aug 2026 12:18:50 +0200 Subject: [PATCH 2/2] docs: note static loop-in HTLC recovery --- docs/release-notes/release-notes-next.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index cc1ace3c8..c179cbe81 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -45,6 +45,9 @@ `loopd` failed with `exec format error` on ARM hosts. [Issue #1211](https://github.com/lightninglabs/loop/issues/1211) +* Static Address Loop In timeout recovery now spends the exact HTLC output + confirmed on chain, including server-published backup fee variants. + * Static Address withdrawals now follow the transaction that actually replaces an original withdrawal, reconcile partial conflicting spends, durably record confirmed transactions before finalizing deposits, and wait for their