diff --git a/loopdb/sqlc/migrations/000023_static_loopin_change_address.down.sql b/loopdb/sqlc/migrations/000023_static_loopin_change_address.down.sql new file mode 100644 index 00000000..8a018029 --- /dev/null +++ b/loopdb/sqlc/migrations/000023_static_loopin_change_address.down.sql @@ -0,0 +1 @@ +ALTER TABLE static_address_swaps DROP COLUMN change_static_address_id; diff --git a/loopdb/sqlc/migrations/000023_static_loopin_change_address.up.sql b/loopdb/sqlc/migrations/000023_static_loopin_change_address.up.sql new file mode 100644 index 00000000..6383bf76 --- /dev/null +++ b/loopdb/sqlc/migrations/000023_static_loopin_change_address.up.sql @@ -0,0 +1,13 @@ +ALTER TABLE static_address_swaps + ADD change_static_address_id INT REFERENCES static_addresses(id); + +-- Existing fractional swaps sent change back to the legacy static address. +-- Backfill that relation so in-flight swaps remain recoverable after the +-- client starts requiring explicit per-swap change metadata. +UPDATE static_address_swaps +SET change_static_address_id = ( + SELECT id FROM static_addresses ORDER BY id ASC LIMIT 1 +) +WHERE selected_amount > 0 + AND change_static_address_id IS NULL + AND EXISTS (SELECT 1 FROM static_addresses); 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 00000000..caaf9571 --- /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 00000000..1af35c63 --- /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 34a92425..94e9cf1e 100644 --- a/loopdb/sqlc/models.go +++ b/loopdb/sqlc/models.go @@ -152,6 +152,10 @@ type StaticAddressSwap struct { Fast bool 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 4a88c518..ce40bc57 100644 --- a/loopdb/sqlc/queries/static_address_loopin.sql +++ b/loopdb/sqlc/queries/static_address_loopin.sql @@ -10,7 +10,8 @@ INSERT INTO static_address_swaps ( htlc_tx_fee_rate_sat_kw, htlc_timeout_sweep_tx_id, htlc_timeout_sweep_address, - fast + fast, + change_static_address_id ) VALUES ( $1, $2, @@ -22,14 +23,18 @@ INSERT INTO static_address_swaps ( $8, $9, $10, - $11 + $11, + $12 ); -- 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; @@ -64,13 +69,24 @@ INSERT INTO static_address_swap_updates ( SELECT swaps.*, static_address_swaps.*, - htlc_keys.* + htlc_keys.*, + change_address.client_pubkey change_client_pubkey, + change_address.server_pubkey change_server_pubkey, + change_address.expiry change_expiry, + change_address.client_key_family change_client_key_family, + change_address.client_key_index change_client_key_index, + change_address.pkscript change_pkscript, + change_address.protocol_version change_protocol_version, + change_address.initiation_height change_initiation_height FROM swaps JOIN static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash JOIN htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash + LEFT JOIN + static_addresses change_address + ON static_address_swaps.change_static_address_id = change_address.id WHERE swaps.swap_hash = $1; @@ -78,13 +94,24 @@ WHERE SELECT swaps.*, static_address_swaps.*, - htlc_keys.* + htlc_keys.*, + change_address.client_pubkey change_client_pubkey, + change_address.server_pubkey change_server_pubkey, + change_address.expiry change_expiry, + change_address.client_key_family change_client_key_family, + change_address.client_key_index change_client_key_index, + change_address.pkscript change_pkscript, + change_address.protocol_version change_protocol_version, + change_address.initiation_height change_initiation_height FROM swaps JOIN static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash JOIN htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash + LEFT JOIN + static_addresses change_address + ON static_address_swaps.change_static_address_id = change_address.id JOIN static_address_swap_updates u ON swaps.swap_hash = u.swap_hash -- This subquery ensures that we are checking only the latest update for @@ -170,5 +197,3 @@ FROM ) WHERE d.swap_hash = $1; - - diff --git a/loopdb/sqlc/static_address_loopin.sql.go b/loopdb/sqlc/static_address_loopin.sql.go index 8cb2aef6..bc17bec7 100644 --- a/loopdb/sqlc/static_address_loopin.sql.go +++ b/loopdb/sqlc/static_address_loopin.sql.go @@ -180,14 +180,25 @@ 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, - 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 + 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, + change_address.expiry change_expiry, + change_address.client_key_family change_client_key_family, + change_address.client_key_index change_client_key_index, + change_address.pkscript change_pkscript, + change_address.protocol_version change_protocol_version, + change_address.initiation_height change_initiation_height FROM swaps JOIN static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash JOIN htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash + LEFT JOIN + static_addresses change_address + ON static_address_swaps.change_static_address_id = change_address.id WHERE swaps.swap_hash = $1 ` @@ -218,6 +229,10 @@ type GetStaticAddressLoopInSwapRow struct { Fast bool ConfirmationRiskDecision string ConfirmationRiskDecisionTime sql.NullTime + ChangeStaticAddressID sql.NullInt32 + ConfirmedHtlcTxID sql.NullString + ConfirmedHtlcOutputIndex sql.NullInt32 + ConfirmedHtlcOutputValue sql.NullInt64 SwapHash_3 []byte SenderScriptPubkey []byte ReceiverScriptPubkey []byte @@ -225,6 +240,14 @@ type GetStaticAddressLoopInSwapRow struct { ReceiverInternalPubkey []byte ClientKeyFamily int32 ClientKeyIndex int32 + ChangeClientPubkey []byte + ChangeServerPubkey []byte + ChangeExpiry sql.NullInt32 + ChangeClientKeyFamily sql.NullInt32 + ChangeClientKeyIndex sql.NullInt32 + ChangePkscript []byte + ChangeProtocolVersion sql.NullInt32 + ChangeInitiationHeight sql.NullInt32 } func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byte) (GetStaticAddressLoopInSwapRow, error) { @@ -256,6 +279,10 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt &i.Fast, &i.ConfirmationRiskDecision, &i.ConfirmationRiskDecisionTime, + &i.ChangeStaticAddressID, + &i.ConfirmedHtlcTxID, + &i.ConfirmedHtlcOutputIndex, + &i.ConfirmedHtlcOutputValue, &i.SwapHash_3, &i.SenderScriptPubkey, &i.ReceiverScriptPubkey, @@ -263,6 +290,14 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt &i.ReceiverInternalPubkey, &i.ClientKeyFamily, &i.ClientKeyIndex, + &i.ChangeClientPubkey, + &i.ChangeServerPubkey, + &i.ChangeExpiry, + &i.ChangeClientKeyFamily, + &i.ChangeClientKeyIndex, + &i.ChangePkscript, + &i.ChangeProtocolVersion, + &i.ChangeInitiationHeight, ) return i, err } @@ -270,14 +305,25 @@ 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, - 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 + 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, + change_address.expiry change_expiry, + change_address.client_key_family change_client_key_family, + change_address.client_key_index change_client_key_index, + change_address.pkscript change_pkscript, + change_address.protocol_version change_protocol_version, + change_address.initiation_height change_initiation_height FROM swaps JOIN static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash JOIN htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash + LEFT JOIN + static_addresses change_address + ON static_address_swaps.change_static_address_id = change_address.id JOIN static_address_swap_updates u ON swaps.swap_hash = u.swap_hash -- This subquery ensures that we are checking only the latest update for @@ -319,6 +365,10 @@ type GetStaticAddressLoopInSwapsByStatesRow struct { Fast bool ConfirmationRiskDecision string ConfirmationRiskDecisionTime sql.NullTime + ChangeStaticAddressID sql.NullInt32 + ConfirmedHtlcTxID sql.NullString + ConfirmedHtlcOutputIndex sql.NullInt32 + ConfirmedHtlcOutputValue sql.NullInt64 SwapHash_3 []byte SenderScriptPubkey []byte ReceiverScriptPubkey []byte @@ -326,6 +376,14 @@ type GetStaticAddressLoopInSwapsByStatesRow struct { ReceiverInternalPubkey []byte ClientKeyFamily int32 ClientKeyIndex int32 + ChangeClientPubkey []byte + ChangeServerPubkey []byte + ChangeExpiry sql.NullInt32 + ChangeClientKeyFamily sql.NullInt32 + ChangeClientKeyIndex sql.NullInt32 + ChangePkscript []byte + ChangeProtocolVersion sql.NullInt32 + ChangeInitiationHeight sql.NullInt32 } func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dollar_1 sql.NullString) ([]GetStaticAddressLoopInSwapsByStatesRow, error) { @@ -363,6 +421,10 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla &i.Fast, &i.ConfirmationRiskDecision, &i.ConfirmationRiskDecisionTime, + &i.ChangeStaticAddressID, + &i.ConfirmedHtlcTxID, + &i.ConfirmedHtlcOutputIndex, + &i.ConfirmedHtlcOutputValue, &i.SwapHash_3, &i.SenderScriptPubkey, &i.ReceiverScriptPubkey, @@ -370,6 +432,14 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla &i.ReceiverInternalPubkey, &i.ClientKeyFamily, &i.ClientKeyIndex, + &i.ChangeClientPubkey, + &i.ChangeServerPubkey, + &i.ChangeExpiry, + &i.ChangeClientKeyFamily, + &i.ChangeClientKeyIndex, + &i.ChangePkscript, + &i.ChangeProtocolVersion, + &i.ChangeInitiationHeight, ); err != nil { return nil, err } @@ -396,7 +466,8 @@ INSERT INTO static_address_swaps ( htlc_tx_fee_rate_sat_kw, htlc_timeout_sweep_tx_id, htlc_timeout_sweep_address, - fast + fast, + change_static_address_id ) VALUES ( $1, $2, @@ -408,7 +479,8 @@ INSERT INTO static_address_swaps ( $8, $9, $10, - $11 + $11, + $12 ) ` @@ -424,6 +496,7 @@ type InsertStaticAddressLoopInParams struct { HtlcTimeoutSweepTxID sql.NullString HtlcTimeoutSweepAddress string Fast bool + ChangeStaticAddressID sql.NullInt32 } func (q *Queries) InsertStaticAddressLoopIn(ctx context.Context, arg InsertStaticAddressLoopInParams) error { @@ -439,6 +512,7 @@ func (q *Queries) InsertStaticAddressLoopIn(ctx context.Context, arg InsertStati arg.HtlcTimeoutSweepTxID, arg.HtlcTimeoutSweepAddress, arg.Fast, + arg.ChangeStaticAddressID, ) return err } @@ -565,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 3c828b1b..fa6fe2ec 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -1,6 +1,7 @@ package loopin import ( + "bytes" "context" "crypto/rand" "errors" @@ -109,6 +110,29 @@ func (f *FSM) InitHtlcAction(ctx context.Context, } swapInvoiceAmt := swapAmount - f.loopIn.QuotedSwapFee + var changeOutput *swapserverrpc.StaticAddressChangeOutput + if hasChange { + changeAmount := f.loopIn.ExpectedChangeAmount() + f.loopIn.ChangeAddressParams, err = + f.cfg.AddressManager.NewChangeAddress(ctx) + if err != nil { + err = fmt.Errorf("unable to create static address "+ + "change output: %w", err) + + return returnError(err) + } + + changeOutput, err = staticutil.ChangeOutput( + f.loopIn.ChangeAddressParams, changeAmount, + ) + if err != nil { + err = fmt.Errorf("unable to prepare static address "+ + "change output: %w", err) + + return returnError(err) + } + } + // Generate random preimage. var swapPreimage lntypes.Preimage if _, err = rand.Read(swapPreimage[:]); err != nil { @@ -179,6 +203,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, PaymentTimeoutSeconds: f.loopIn.PaymentTimeoutSeconds, Fast: f.loopIn.Fast, DepositToClientPubkeys: depositClientPubkeys, + ChangeOutput: changeOutput, } if f.loopIn.LastHop != nil { loopInReq.LastHop = f.loopIn.LastHop @@ -1147,9 +1172,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( @@ -1190,6 +1220,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 { @@ -1367,6 +1401,51 @@ 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() + 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 3d2319f2..b6872b59 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -12,12 +12,14 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/version" "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" @@ -899,6 +901,7 @@ func TestCheckDepositsAvailableRejectsDivergentDepositOutpoints( // update failure must not roll back the action or state transition. func TestInitHtlcActionIgnoresSendUpdateErrorAfterPersistence(t *testing.T) { mockLnd := test.NewMockLnd() + _, depositClientPubkey := test.CreateKey(21) _, serverKey := test.CreateKey(22) server := &mockStaticAddressServer{ @@ -913,6 +916,10 @@ func TestInitHtlcActionIgnoresSendUpdateErrorAfterPersistence(t *testing.T) { Index: 0, }, Value: 500_000, + AddressParams: &address.Parameters{ + ClientPubkey: depositClientPubkey, + PkScript: []byte{0x51, 0x20, 0x02}, + }, } loopIn := &StaticAddressLoopIn{ @@ -933,6 +940,7 @@ func TestInitHtlcActionIgnoresSendUpdateErrorAfterPersistence(t *testing.T) { Server: server, DepositManager: &noopDepositManager{}, LndClient: mockLnd.Client, + InvoicesClient: mockLnd.LndServices.Invoices, WalletKit: mockLnd.WalletKit, ChainParams: mockLnd.ChainParams, Store: &mockStore{}, @@ -956,6 +964,82 @@ func TestInitHtlcActionIgnoresSendUpdateErrorAfterPersistence(t *testing.T) { require.True(t, sendUpdateCalled) } +// TestInitHtlcActionSendsChangeOutput asserts that fractional loop-ins create +// and send an operation-specific static change output to the server. +func TestInitHtlcActionSendsChangeOutput(t *testing.T) { + t.Parallel() + + mockLnd := test.NewMockLnd() + _, depositClientPubkey := test.CreateKey(31) + _, changeClientPubkey := test.CreateKey(32) + _, serverKey := test.CreateKey(33) + + server := &mockStaticAddressServer{ + response: testStaticAddressLoopInResponse( + serverKey.SerializeCompressed(), + ), + } + + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 0, + }, + Value: 500_000, + AddressParams: &address.Parameters{ + ClientPubkey: depositClientPubkey, + PkScript: []byte{0x51, 0x20, 0x02}, + }, + } + changeParams := &address.Parameters{ + ID: 1, + ClientPubkey: changeClientPubkey, + PkScript: []byte{0x51, 0x20, 0x01}, + } + + loopIn := &StaticAddressLoopIn{ + Deposits: []*deposit.Deposit{dep}, + DepositOutpoints: []string{dep.OutPoint.String()}, + SelectedAmount: 300_000, + QuotedSwapFee: 1_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + PaymentTimeoutSeconds: 3_600, + } + + f := &FSM{ + StateMachine: &fsm.StateMachine{}, + cfg: &Config{ + Server: server, + AddressManager: &mockAddressManager{params: changeParams}, + DepositManager: &noopDepositManager{}, + LndClient: mockLnd.Client, + WalletKit: mockLnd.WalletKit, + ChainParams: mockLnd.ChainParams, + Store: &mockStore{}, + ValidateLoopInContract: testValidateLoopInContract, + MaxStaticAddrHtlcFeePercentage: 1, + MaxStaticAddrHtlcBackupFeePercentage: 1, + }, + loopIn: loopIn, + } + + event := f.InitHtlcAction(t.Context(), nil) + require.Equal(t, OnHtlcInitiated, event) + require.Nil(t, f.LastActionError) + require.NotNil(t, server.request.ChangeOutput) + require.EqualValues(t, 200_000, server.request.ChangeOutput.Amount) + require.Equal( + t, changeClientPubkey.SerializeCompressed(), + server.request.ChangeOutput.StaticAddress.GetPubkey(), + ) + require.Equal( + t, changeParams.PkScript, + server.request.ChangeOutput.StaticAddress.GetPkScript(), + ) + require.Same(t, changeParams, loopIn.ChangeAddressParams) +} + // mockStaticAddressServer captures static-address loop-in requests in tests. type mockStaticAddressServer struct { swapserverrpc.StaticAddressServerClient @@ -994,6 +1078,70 @@ func testStaticAddressLoopInResponse( } } +type recordingLoopInStore struct { + mockStore + + updates []*StaticAddressLoopIn +} + +func (s *recordingLoopInStore) UpdateLoopIn(_ context.Context, + loopIn *StaticAddressLoopIn) error { + + s.updates = append(s.updates, loopIn) + + 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(), + } + htlc, err := loopIn.getHtlc(test.NewMockLnd().ChainParams) + require.NoError(t, err) + + htlcValue := int64(123_456) + tx := wire.NewMsgTx(2) + 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, + } + + 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) +} + // testStaticAddressRouteHints returns deterministic route hints for static // loop-in invoice regression tests. func testStaticAddressRouteHints() [][]zpay32.HopHint { @@ -1102,10 +1250,18 @@ 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) + htlcTx.AddTxOut(&wire.TxOut{ + Value: 1, + PkScript: htlc.PkScript, + }) resultChan := make(chan fsm.EventType, 1) go func() { @@ -1124,7 +1280,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: @@ -3367,6 +3523,13 @@ func (m *mockAddressManager) GetStaticAddress(_ context.Context) ( return nil, nil } +// NewChangeAddress returns configured parameters for tests that need change. +func (m *mockAddressManager) NewChangeAddress(_ context.Context) ( + *address.Parameters, error) { + + return m.params, nil +} + // noopDepositManager is a stub DepositManager used to satisfy FSM config. type noopDepositManager struct { deposits []*deposit.Deposit diff --git a/staticaddr/loopin/interface.go b/staticaddr/loopin/interface.go index d54355a7..aa54e727 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swapserverrpc" @@ -41,6 +42,10 @@ type AddressManager interface { // GetStaticAddress returns the deposit address for the given client and // server public keys. GetStaticAddress(ctx context.Context) (*script.StaticAddress, error) + + // NewChangeAddress derives and persists a fresh static address from the + // change key family for this operation's change output. + NewChangeAddress(ctx context.Context) (*address.Parameters, error) } // DepositManager handles the interaction of loop-ins with deposits. diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index d8710b1c..bf3467f8 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -18,6 +18,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/staticutil" @@ -166,6 +167,11 @@ type StaticAddressLoopIn struct { // Address is the address script that is used for the swap. Address *script.StaticAddress + // ChangeAddressParams are the static address parameters for the change + // output that belongs to this swap. It is set only when SelectedAmount + // leaves non-dust change. + ChangeAddressParams *address.Parameters + // HTLC fields. // HtlcTxFeeRate is the fee rate that is used for the htlc transaction. @@ -182,6 +188,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 @@ -306,11 +322,10 @@ func (l *StaticAddressLoopIn) createHtlcTx(chainParams *chaincfg.Params, // change. var ( swapAmt = l.TotalDepositAmount() - changeAmount btcutil.Amount + changeAmount = l.ExpectedChangeAmount() ) if l.SelectedAmount > 0 { swapAmt = l.SelectedAmount - changeAmount = l.TotalDepositAmount() - l.SelectedAmount } // Calculate htlc tx fee for server provided fee rate. @@ -346,9 +361,14 @@ func (l *StaticAddressLoopIn) createHtlcTx(chainParams *chaincfg.Params, // We expect change to be sent back to our static address output script. if changeAmount > 0 { + if l.ChangeAddressParams == nil { + return nil, fmt.Errorf("missing static address change " + + "parameters") + } + msgTx.AddTxOut(&wire.TxOut{ Value: int64(changeAmount), - PkScript: l.AddressParams.PkScript, + PkScript: l.ChangeAddressParams.PkScript, }) } @@ -408,36 +428,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 { - if bytes.Equal( - htlcTx.TxOut[0].PkScript, l.AddressParams.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. @@ -448,7 +450,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, @@ -487,6 +488,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 @@ -508,6 +559,22 @@ func (l *StaticAddressLoopIn) TotalDepositAmount() btcutil.Amount { return total } +// ExpectedChangeAmount returns the change that a fractional loop-in should send +// to its generated static change address. A full-amount loop-in has no change. +func (l *StaticAddressLoopIn) ExpectedChangeAmount() btcutil.Amount { + if l.SelectedAmount <= 0 { + return 0 + } + + totalDepositAmount := l.TotalDepositAmount() + changeAmount := totalDepositAmount - l.SelectedAmount + if changeAmount <= 0 || changeAmount >= totalDepositAmount { + return 0 + } + + return changeAmount +} + // 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 swap's configured payment diff --git a/staticaddr/loopin/loopin_test.go b/staticaddr/loopin/loopin_test.go index 8b0892e6..574c91d7 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" @@ -77,7 +78,8 @@ func TestCreateHtlcSweepTxSweepValue(t *testing.T) { Hash: chainhash.Hash{0xaa}, Index: 0, }, - Value: depositValue, + Value: depositValue, + AddressParams: addrParams, }, } @@ -96,7 +98,7 @@ func TestCreateHtlcSweepTxSweepValue(t *testing.T) { ClientPubkey: clientKey.PubKey(), ServerPubkey: serverKey.PubKey(), Deposits: deposits, - AddressParams: addrParams, + ChangeAddressParams: addrParams, HtlcTxFeeRate: feeRate, SelectedAmount: selectedAmount, PaymentTimeoutSeconds: 3600, @@ -183,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/manager.go b/staticaddr/loopin/manager.go index 226b8900..e5780d8e 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -21,7 +21,6 @@ import ( "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/staticaddr/deposit" - "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/staticutil" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/input" @@ -333,7 +332,7 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, // If the user selected an amount that is less than the total deposit // amount we'll check that the server sends us the correct change amount // back to our static address. - err = m.checkChange(ctx, sweepTx, loopIn.AddressParams) + err = m.checkChange(ctx, sweepTx) if err != nil { return err } @@ -471,7 +470,7 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, // swaps with identical change outputs. The client needs to ensure that any // swap referenced by the inputs has a respective change output in the batch. func (m *Manager) checkChange(ctx context.Context, - sweepTx *wire.MsgTx, changeAddr *script.Parameters) error { + sweepTx *wire.MsgTx) error { prevOuts := make([]string, len(sweepTx.TxIn)) for i, in := range sweepTx.TxIn { @@ -496,42 +495,67 @@ func (m *Manager) checkChange(ctx context.Context, return err } - var expectedChange btcutil.Amount + var expectedChanges []*wire.TxOut for swapHash := range swapHashes { loopIn, err := m.cfg.Store.GetLoopInByHash(ctx, swapHash) if err != nil { return err } - totalDepositAmount := loopIn.TotalDepositAmount() - changeAmt := totalDepositAmount - loopIn.SelectedAmount - if changeAmt > 0 && changeAmt < totalDepositAmount { - log.Debugf("expected change output to our "+ - "static address, total_deposit_amount=%v, "+ - "selected_amount=%v, "+ - "expected_change_amount=%v ", - totalDepositAmount, loopIn.SelectedAmount, - changeAmt) - - expectedChange += changeAmt + changeAmt := loopIn.ExpectedChangeAmount() + if changeAmt == 0 { + continue } + + if loopIn.ChangeAddressParams == nil { + return fmt.Errorf("missing change address for swap %x", + swapHash[:]) + } + + log.Debugf("expected change output to static address, "+ + "swap_hash=%x, selected_amount=%v, "+ + "expected_change_amount=%v", swapHash[:], + loopIn.SelectedAmount, changeAmt) + + expectedChanges = append(expectedChanges, &wire.TxOut{ + Value: int64(changeAmt), + PkScript: loopIn.ChangeAddressParams.PkScript, + }) } - if expectedChange == 0 { + if len(expectedChanges) == 0 { return nil } - for _, out := range sweepTx.TxOut { - if out.Value == int64(expectedChange) && - bytes.Equal(out.PkScript, changeAddr.PkScript) { + // Match expected change outputs as a multiset. This rejects batched + // transactions that collapse two equal client change outputs into one + // output unless the protocol explicitly negotiates such aggregation. + matchedOutputs := make([]bool, len(sweepTx.TxOut)) + for _, expected := range expectedChanges { + var found bool + for i, out := range sweepTx.TxOut { + if matchedOutputs[i] { + continue + } - // We found the expected change output. - return nil + if out.Value == expected.Value && + bytes.Equal(out.PkScript, expected.PkScript) { + + matchedOutputs[i] = true + found = true + break + } } + + if found { + continue + } + + return fmt.Errorf("couldn't find expected change of %v "+ + "satoshis sent to static address", expected.Value) } - return fmt.Errorf("couldn't find expected change of %v "+ - "satoshis sent to our static address", expectedChange) + return nil } // recover stars a loop-in state machine for each non-final loop-in to pick up diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index 67f744f6..dcafdea8 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -666,9 +666,9 @@ func TestCheckChange(t *testing.T) { var hash lntypes.Hash hash[0] = h li := &StaticAddressLoopIn{ - Deposits: deposits, - SelectedAmount: selected, - AddressParams: changeAddr, + Deposits: deposits, + SelectedAmount: selected, + ChangeAddressParams: changeAddr, } return hash, li } @@ -723,7 +723,6 @@ func TestCheckChange(t *testing.T) { name string inDeps []*deposit.Deposit // deposits referenced by tx inputs outputs []*wire.TxOut // outputs in sweep tx - addr *script.Parameters expectErr bool expectedErrMsg string } @@ -739,7 +738,6 @@ func TestCheckChange(t *testing.T) { PkScript: serverAddr.PkScript, }, }, - addr: changeAddr, }, { name: "single swap change present", @@ -754,43 +752,59 @@ func TestCheckChange(t *testing.T) { PkScript: changeAddr.PkScript, }, }, - addr: changeAddr, }, { name: "multiple swaps different change amounts", - inDeps: []*deposit.Deposit{s2d1, s3d1}, // B(500)+C(400)=900 + inDeps: []*deposit.Deposit{s2d1, s3d1}, // B(500)+C(400) outputs: []*wire.TxOut{ { Value: 1337, PkScript: serverAddr.PkScript, }, { - Value: 900, + Value: 500, + PkScript: changeAddr.PkScript, + }, + { + Value: 400, PkScript: changeAddr.PkScript, }, }, - addr: changeAddr, }, { - name: "two swaps with identical change values sum correctly", - inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400)=800 + name: "two swaps with identical change values both present", + inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400) outputs: []*wire.TxOut{ { Value: 1337, PkScript: serverAddr.PkScript, }, + { + Value: 400, + PkScript: changeAddr.PkScript, + }, + { + Value: 400, + PkScript: changeAddr.PkScript, + }, + }, + }, + { + name: "collapsed identical change output rejected", + inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400) + outputs: []*wire.TxOut{ { Value: 800, PkScript: changeAddr.PkScript, }, }, - addr: changeAddr, + expectErr: true, + expectedErrMsg: "couldn't find expected change", }, { name: "missing change output results in error", inDeps: []*deposit.Deposit{s2d1}, // expect 500 outputs: []*wire.TxOut{}, - addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, @@ -807,7 +821,6 @@ func TestCheckChange(t *testing.T) { PkScript: otherAddr.PkScript, }, }, - addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, @@ -824,7 +837,6 @@ func TestCheckChange(t *testing.T) { PkScript: changeAddr.PkScript, }, }, - addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, @@ -845,7 +857,6 @@ func TestCheckChange(t *testing.T) { PkScript: otherAddr.PkScript, }, }, - addr: changeAddr, }, } @@ -868,7 +879,7 @@ func TestCheckChange(t *testing.T) { mgr.cfg.DepositManager = mdm tx := makeSweepTx(inputs, tc.outputs) - err := mgr.checkChange(ctx, tx, tc.addr) + err := mgr.checkChange(ctx, tx) if tc.expectErr { require.Error(t, err) if tc.expectedErrMsg != "" { diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index cd102850..b8fa1ee6 100644 --- a/staticaddr/loopin/sql_store.go +++ b/staticaddr/loopin/sql_store.go @@ -14,6 +14,7 @@ import ( "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/loopdb/sqlc" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightningnetwork/lnd/clock" @@ -294,6 +295,17 @@ func (s *SqlStore) CreateLoopIn(ctx context.Context, PaymentTimeoutSeconds: int32(loopIn.PaymentTimeoutSeconds), Fast: loopIn.Fast, } + if loopIn.ChangeAddressParams != nil { + if loopIn.ChangeAddressParams.ID == 0 { + return errors.New("static address change parameters " + + "missing database ID") + } + + staticAddressLoopInParams.ChangeStaticAddressID = sql.NullInt32{ + Int32: loopIn.ChangeAddressParams.ID, + Valid: true, + } + } updateTime := sqlStoreUpdateTime(s.clock) updateArgs := sqlc.InsertStaticAddressMetaUpdateParams{ @@ -357,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), @@ -364,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) @@ -575,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( @@ -638,6 +677,11 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, } depositList = orderDepositsBySnapshot(depositList, depositOutpoints) + changeAddressParams, err := toChangeAddressParameters(swap) + if err != nil { + return nil, err + } + loopIn := &StaticAddressLoopIn{ SwapHash: swapHash, SwapPreimage: swapPreImage, @@ -670,7 +714,13 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, ), HtlcTimeoutSweepAddress: timeoutAddress, HtlcTimeoutSweepTxHash: htlcTimeoutSweepTxHash, - Deposits: depositList, + HtlcTxHash: htlcTxHash, + HtlcOutputIndex: uint32(swap.ConfirmedHtlcOutputIndex.Int32), + HtlcOutputValue: btcutil.Amount( + swap.ConfirmedHtlcOutputValue.Int64, + ), + Deposits: depositList, + ChangeAddressParams: changeAddressParams, } if swap.ConfirmationRiskDecisionTime.Valid { loopIn.ConfirmationRiskDecisionTime = @@ -719,3 +769,41 @@ func orderDepositsBySnapshot(deposits []*deposit.Deposit, return orderedDeposits } + +// toChangeAddressParameters converts the optional joined static address row +// into the change address parameters used to verify batched sweepless sweeps. +func toChangeAddressParameters(row sqlc.GetStaticAddressLoopInSwapRow) ( + *address.Parameters, error) { + + if !row.ChangeStaticAddressID.Valid { + return nil, nil + } + + clientKey, err := btcec.ParsePubKey(row.ChangeClientPubkey) + if err != nil { + return nil, err + } + + serverKey, err := btcec.ParsePubKey(row.ChangeServerPubkey) + if err != nil { + return nil, err + } + + return &address.Parameters{ + ID: row.ChangeStaticAddressID.Int32, + ClientPubkey: clientKey, + ServerPubkey: serverKey, + Expiry: uint32(row.ChangeExpiry.Int32), + PkScript: row.ChangePkscript, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily( + row.ChangeClientKeyFamily.Int32, + ), + Index: uint32(row.ChangeClientKeyIndex.Int32), + }, + ProtocolVersion: version.AddressProtocolVersion( + row.ChangeProtocolVersion.Int32, + ), + InitiationHeight: row.ChangeInitiationHeight.Int32, + }, nil +} diff --git a/staticaddr/loopin/sql_store_test.go b/staticaddr/loopin/sql_store_test.go index c08d940f..4310264a 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -544,6 +544,70 @@ 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, + }, + } + 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 + 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()) +} + // TestGetLoopInByHashPreservesStoredDepositOutpoints ensures recovered loop-ins // keep the original outpoint snapshot stored when the swap was created. func TestGetLoopInByHashPreservesStoredDepositOutpoints(t *testing.T) { diff --git a/staticaddr/staticutil/utils.go b/staticaddr/staticutil/utils.go index 73fb97b4..05d131a0 100644 --- a/staticaddr/staticutil/utils.go +++ b/staticaddr/staticutil/utils.go @@ -12,6 +12,7 @@ import ( "github.com/btcsuite/btcd/txscript" "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/swapserverrpc" "github.com/lightningnetwork/lnd/input" @@ -96,6 +97,35 @@ func DepositClientPubkeys(deposits []*deposit.Deposit) ( return clientPubkeys, nil } +// ChangeOutput converts a locally generated static address into the RPC change +// descriptor sent to the server. The descriptor binds the expected script, +// amount and client key so the server can derive and verify the same address. +func ChangeOutput(params *address.Parameters, + amount btcutil.Amount) (*swapserverrpc.StaticAddressChangeOutput, error) { + + if amount <= 0 { + return nil, nil + } + if params == nil { + return nil, fmt.Errorf("missing static address change parameters") + } + if params.ClientPubkey == nil { + return nil, fmt.Errorf("missing static address change client " + + "pubkey") + } + if len(params.PkScript) == 0 { + return nil, fmt.Errorf("missing static address change pkscript") + } + + return &swapserverrpc.StaticAddressChangeOutput{ + StaticAddress: &swapserverrpc.StaticAddressDescriptor{ + Pubkey: params.ClientPubkey.SerializeCompressed(), + PkScript: params.PkScript, + }, + Amount: int64(amount), + }, nil +} + // CreateMusig2Sessions creates a musig2 session for a number of deposits. func CreateMusig2Sessions(ctx context.Context, signer lndclient.SignerClient, deposits []*deposit.Deposit) ( diff --git a/staticaddr/staticutil/utils_test.go b/staticaddr/staticutil/utils_test.go index 2320eacb..d8d42811 100644 --- a/staticaddr/staticutil/utils_test.go +++ b/staticaddr/staticutil/utils_test.go @@ -237,6 +237,45 @@ func TestDepositClientPubkeysRejectsInvalidDeposits(t *testing.T) { }) } +func TestChangeOutput(t *testing.T) { + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + params := &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + PkScript: []byte{0x51, 0x20, 0x01}, + } + amount := btcutil.Amount(12345) + + changeOutput, err := ChangeOutput(params, amount) + require.NoError(t, err) + require.Equal( + t, clientKey.PubKey().SerializeCompressed(), + changeOutput.StaticAddress.GetPubkey(), + ) + require.Equal(t, params.PkScript, changeOutput.StaticAddress.GetPkScript()) + require.EqualValues(t, amount, changeOutput.Amount) + + changeOutput, err = ChangeOutput(params, 0) + require.NoError(t, err) + require.Nil(t, changeOutput) +} + +func TestChangeOutputRejectsInvalidParams(t *testing.T) { + _, err := ChangeOutput(nil, 100) + require.ErrorContains(t, err, "missing static address change parameters") + + _, err = ChangeOutput(&address.Parameters{}, 100) + require.ErrorContains(t, err, "missing static address change client pubkey") + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + _, err = ChangeOutput(&address.Parameters{ + ClientPubkey: clientKey.PubKey(), + }, 100) + require.ErrorContains(t, err, "missing static address change pkscript") +} + func TestGetPrevoutInfo_ConversionAndSorting(t *testing.T) { // Helper to create a hash from string. must := func(s string) chainhash.Hash {