From 269f45d005a9c55bbb49d53aa4a780db36b9f3ac Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 11:55:44 +0200 Subject: [PATCH 01/30] swap: reserve multi-address key families Reserve separate key families for static receive and change addresses. This keeps derived keys out of the legacy static-address and HTLC key streams. --- swap/keychain.go | 13 +++++++++++-- swap/keychain_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 swap/keychain_test.go diff --git a/swap/keychain.go b/swap/keychain.go index 37106950c..eded48133 100644 --- a/swap/keychain.go +++ b/swap/keychain.go @@ -5,7 +5,16 @@ var ( // spending of the htlc. KeyFamily = int32(99) - // StaticAddressKeyFamily is the key family used to generate static - // address keys. + // StaticAddressKeyFamily is the legacy static-address key family. It is + // used for the V0 single static-address key and for static-address HTLC + // keys. StaticAddressKeyFamily = int32(42060) + + // StaticMultiAddressKeyFamily is the key family used to generate + // externally visible multi-address static-address receive keys. + StaticMultiAddressKeyFamily = int32(42061) + + // StaticAddressChangeKeyFamily is the key family used to generate + // static-address change outputs. + StaticAddressChangeKeyFamily = int32(42062) ) diff --git a/swap/keychain_test.go b/swap/keychain_test.go new file mode 100644 index 000000000..d45a38940 --- /dev/null +++ b/swap/keychain_test.go @@ -0,0 +1,24 @@ +package swap + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestStaticAddressKeyFamiliesAreDisjoint documents the key-family split used +// by static-address HTLC, receive and change key derivation. +func TestStaticAddressKeyFamiliesAreDisjoint(t *testing.T) { + families := map[int32]string{ + KeyFamily: "swap htlc", + StaticAddressKeyFamily: "legacy static address and htlc", + StaticMultiAddressKeyFamily: "multi-address receive", + StaticAddressChangeKeyFamily: "static-address change", + } + + require.Len(t, families, 4) + require.EqualValues(t, 99, KeyFamily) + require.EqualValues(t, 42060, StaticAddressKeyFamily) + require.EqualValues(t, 42061, StaticMultiAddressKeyFamily) + require.EqualValues(t, 42062, StaticAddressChangeKeyFamily) +} From 96ae6624e2dbe382a7c907c834a902f03176a50e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 11:58:03 +0200 Subject: [PATCH 02/30] loopdb: persist deposit address ownership Associate every deposit with the static address parameters that created it. This lets restored deposits recover the correct script and signing keys instead of assuming the legacy root address. --- .../000022_deposit_static_address_id.down.sql | 1 + .../000022_deposit_static_address_id.up.sql | 8 + loopdb/sqlc/models.go | 1 + loopdb/sqlc/querier.go | 8 +- .../sqlc/queries/static_address_deposits.sql | 49 ++++-- loopdb/sqlc/queries/static_address_loopin.sql | 10 +- loopdb/sqlc/queries/static_addresses.sql | 14 +- loopdb/sqlc/static_address_deposits.sql.go | 156 +++++++++++++++-- loopdb/sqlc/static_address_loopin.sql.go | 29 +++- loopdb/sqlc/static_addresses.sql.go | 36 ++++ staticaddr/address/sql_store.go | 22 ++- staticaddr/deposit/deposit.go | 20 +++ staticaddr/deposit/manager.go | 17 ++ staticaddr/deposit/manager_reconcile_test.go | 30 +++- staticaddr/deposit/sql_store.go | 161 +++++++++++++++++- staticaddr/deposit/sql_store_test.go | 110 +++++++++++- staticaddr/loopin/sql_store.go | 12 +- staticaddr/loopin/sql_store_test.go | 101 +++++++++++ staticaddr/script/parameters.go | 4 + 19 files changed, 743 insertions(+), 46 deletions(-) create mode 100644 loopdb/sqlc/migrations/000022_deposit_static_address_id.down.sql create mode 100644 loopdb/sqlc/migrations/000022_deposit_static_address_id.up.sql diff --git a/loopdb/sqlc/migrations/000022_deposit_static_address_id.down.sql b/loopdb/sqlc/migrations/000022_deposit_static_address_id.down.sql new file mode 100644 index 000000000..e112a7b1f --- /dev/null +++ b/loopdb/sqlc/migrations/000022_deposit_static_address_id.down.sql @@ -0,0 +1 @@ +ALTER TABLE deposits DROP COLUMN static_address_id; diff --git a/loopdb/sqlc/migrations/000022_deposit_static_address_id.up.sql b/loopdb/sqlc/migrations/000022_deposit_static_address_id.up.sql new file mode 100644 index 000000000..4246b116e --- /dev/null +++ b/loopdb/sqlc/migrations/000022_deposit_static_address_id.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE deposits ADD static_address_id INT REFERENCES static_addresses(id); + +UPDATE deposits +SET static_address_id = ( + SELECT id FROM static_addresses ORDER BY id ASC LIMIT 1 +) +WHERE static_address_id IS NULL + AND EXISTS (SELECT 1 FROM static_addresses); diff --git a/loopdb/sqlc/models.go b/loopdb/sqlc/models.go index 78a75d042..34a924256 100644 --- a/loopdb/sqlc/models.go +++ b/loopdb/sqlc/models.go @@ -20,6 +20,7 @@ type Deposit struct { ExpirySweepTxid []byte FinalizedWithdrawalTx sql.NullString SwapHash []byte + StaticAddressID sql.NullInt32 } type DepositUpdate struct { diff --git a/loopdb/sqlc/querier.go b/loopdb/sqlc/querier.go index ba3c35eb9..03df10434 100644 --- a/loopdb/sqlc/querier.go +++ b/loopdb/sqlc/querier.go @@ -10,7 +10,7 @@ import ( ) type Querier interface { - AllDeposits(ctx context.Context) ([]Deposit, error) + AllDeposits(ctx context.Context) ([]AllDepositsRow, error) AllStaticAddresses(ctx context.Context) ([]StaticAddress, error) CancelBatch(ctx context.Context, id int32) error CreateDeposit(ctx context.Context, arg CreateDepositParams) error @@ -18,19 +18,20 @@ type Querier interface { CreateStaticAddress(ctx context.Context, arg CreateStaticAddressParams) error CreateWithdrawal(ctx context.Context, arg CreateWithdrawalParams) error CreateWithdrawalDeposit(ctx context.Context, arg CreateWithdrawalDepositParams) error - DepositForOutpoint(ctx context.Context, arg DepositForOutpointParams) (Deposit, error) + DepositForOutpoint(ctx context.Context, arg DepositForOutpointParams) (DepositForOutpointRow, error) DepositIDsForSwapHash(ctx context.Context, swapHash []byte) ([][]byte, error) DepositsForSwapHash(ctx context.Context, swapHash []byte) ([]DepositsForSwapHashRow, error) FetchLiquidityParams(ctx context.Context) ([]byte, error) GetAllWithdrawals(ctx context.Context) ([]Withdrawal, error) GetBatchSweeps(ctx context.Context, batchID int32) ([]Sweep, error) GetBatchSweptAmount(ctx context.Context, batchID int32) (int64, error) - GetDeposit(ctx context.Context, depositID []byte) (Deposit, error) + GetDeposit(ctx context.Context, depositID []byte) (GetDepositRow, error) GetInstantOutSwap(ctx context.Context, swapHash []byte) (GetInstantOutSwapRow, error) GetInstantOutSwapUpdates(ctx context.Context, swapHash []byte) ([]InstantoutUpdate, error) GetInstantOutSwaps(ctx context.Context) ([]GetInstantOutSwapsRow, error) GetLastUpdateID(ctx context.Context, swapHash []byte) (int32, error) GetLatestDepositUpdate(ctx context.Context, depositID []byte) (DepositUpdate, error) + GetLegacyAddress(ctx context.Context) (StaticAddress, error) GetLoopInSwap(ctx context.Context, swapHash []byte) (GetLoopInSwapRow, error) GetLoopInSwapUpdates(ctx context.Context, swapHash []byte) ([]StaticAddressSwapUpdate, error) GetLoopInSwaps(ctx context.Context) ([]GetLoopInSwapsRow, error) @@ -42,6 +43,7 @@ type Querier interface { GetReservationUpdates(ctx context.Context, reservationID []byte) ([]ReservationUpdate, error) GetReservations(ctx context.Context) ([]Reservation, error) GetStaticAddress(ctx context.Context, pkscript []byte) (StaticAddress, error) + GetStaticAddressID(ctx context.Context, pkscript []byte) (int32, error) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byte) (GetStaticAddressLoopInSwapRow, error) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dollar_1 sql.NullString) ([]GetStaticAddressLoopInSwapsByStatesRow, error) GetSwapUpdates(ctx context.Context, swapHash []byte) ([]SwapUpdate, error) diff --git a/loopdb/sqlc/queries/static_address_deposits.sql b/loopdb/sqlc/queries/static_address_deposits.sql index 2987e469e..0d58ba19f 100644 --- a/loopdb/sqlc/queries/static_address_deposits.sql +++ b/loopdb/sqlc/queries/static_address_deposits.sql @@ -7,7 +7,8 @@ INSERT INTO deposits ( confirmation_height, timeout_sweep_pk_script, expiry_sweep_txid, - finalized_withdrawal_tx + finalized_withdrawal_tx, + static_address_id ) VALUES ( $1, $2, @@ -16,7 +17,8 @@ INSERT INTO deposits ( $5, $6, $7, - $8 + $8, + $9 ); -- name: UpdateDeposit :exec @@ -43,17 +45,35 @@ INSERT INTO deposit_updates ( -- name: GetDeposit :one SELECT - * + d.*, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id WHERE deposit_id = $1; -- name: DepositForOutpoint :one SELECT - * + d.*, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id WHERE tx_hash = $1 AND @@ -61,11 +81,20 @@ AND -- name: AllDeposits :many SELECT - * + d.*, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id ORDER BY - id ASC; + d.id ASC; -- name: GetLatestDepositUpdate :one SELECT @@ -76,4 +105,4 @@ WHERE deposit_id = $1 ORDER BY update_timestamp DESC -LIMIT 1; \ No newline at end of file +LIMIT 1; diff --git a/loopdb/sqlc/queries/static_address_loopin.sql b/loopdb/sqlc/queries/static_address_loopin.sql index b4fca5d45..ecd252f6d 100644 --- a/loopdb/sqlc/queries/static_address_loopin.sql +++ b/loopdb/sqlc/queries/static_address_loopin.sql @@ -147,10 +147,19 @@ WHERE -- name: DepositsForSwapHash :many SELECT d.*, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height, u.update_state, u.update_timestamp FROM deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id LEFT JOIN deposit_updates u ON u.id = ( SELECT id @@ -162,4 +171,3 @@ FROM WHERE d.swap_hash = $1; - diff --git a/loopdb/sqlc/queries/static_addresses.sql b/loopdb/sqlc/queries/static_addresses.sql index c613cfd93..cc86fa7e2 100644 --- a/loopdb/sqlc/queries/static_addresses.sql +++ b/loopdb/sqlc/queries/static_addresses.sql @@ -1,10 +1,15 @@ -- name: AllStaticAddresses :many -SELECT * FROM static_addresses; +SELECT * FROM static_addresses +ORDER BY id ASC; -- name: GetStaticAddress :one SELECT * FROM static_addresses WHERE pkscript=$1; +-- name: GetStaticAddressID :one +SELECT id FROM static_addresses +WHERE pkscript=$1; + -- name: CreateStaticAddress :exec INSERT INTO static_addresses ( client_pubkey, @@ -24,4 +29,9 @@ INSERT INTO static_addresses ( $6, $7, $8 - ); \ No newline at end of file + ); + +-- name: GetLegacyAddress :one +SELECT * FROM static_addresses +ORDER BY id ASC +LIMIT 1; diff --git a/loopdb/sqlc/static_address_deposits.sql.go b/loopdb/sqlc/static_address_deposits.sql.go index 191f1f563..cd984355c 100644 --- a/loopdb/sqlc/static_address_deposits.sql.go +++ b/loopdb/sqlc/static_address_deposits.sql.go @@ -13,22 +13,53 @@ import ( const allDeposits = `-- name: AllDeposits :many SELECT - id, deposit_id, tx_hash, out_index, amount, confirmation_height, timeout_sweep_pk_script, expiry_sweep_txid, finalized_withdrawal_tx, swap_hash + d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, d.static_address_id, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id ORDER BY - id ASC + d.id ASC ` -func (q *Queries) AllDeposits(ctx context.Context) ([]Deposit, error) { +type AllDepositsRow struct { + ID int32 + DepositID []byte + TxHash []byte + OutIndex int32 + Amount int64 + ConfirmationHeight int64 + TimeoutSweepPkScript []byte + ExpirySweepTxid []byte + FinalizedWithdrawalTx sql.NullString + SwapHash []byte + StaticAddressID sql.NullInt32 + ClientPubkey []byte + ServerPubkey []byte + Expiry sql.NullInt32 + ClientKeyFamily sql.NullInt32 + ClientKeyIndex sql.NullInt32 + Pkscript []byte + ProtocolVersion sql.NullInt32 + InitiationHeight sql.NullInt32 +} + +func (q *Queries) AllDeposits(ctx context.Context) ([]AllDepositsRow, error) { rows, err := q.db.QueryContext(ctx, allDeposits) if err != nil { return nil, err } defer rows.Close() - var items []Deposit + var items []AllDepositsRow for rows.Next() { - var i Deposit + var i AllDepositsRow if err := rows.Scan( &i.ID, &i.DepositID, @@ -40,6 +71,15 @@ func (q *Queries) AllDeposits(ctx context.Context) ([]Deposit, error) { &i.ExpirySweepTxid, &i.FinalizedWithdrawalTx, &i.SwapHash, + &i.StaticAddressID, + &i.ClientPubkey, + &i.ServerPubkey, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.Pkscript, + &i.ProtocolVersion, + &i.InitiationHeight, ); err != nil { return nil, err } @@ -63,7 +103,8 @@ INSERT INTO deposits ( confirmation_height, timeout_sweep_pk_script, expiry_sweep_txid, - finalized_withdrawal_tx + finalized_withdrawal_tx, + static_address_id ) VALUES ( $1, $2, @@ -72,7 +113,8 @@ INSERT INTO deposits ( $5, $6, $7, - $8 + $8, + $9 ) ` @@ -85,6 +127,7 @@ type CreateDepositParams struct { TimeoutSweepPkScript []byte ExpirySweepTxid []byte FinalizedWithdrawalTx sql.NullString + StaticAddressID sql.NullInt32 } func (q *Queries) CreateDeposit(ctx context.Context, arg CreateDepositParams) error { @@ -97,15 +140,25 @@ func (q *Queries) CreateDeposit(ctx context.Context, arg CreateDepositParams) er arg.TimeoutSweepPkScript, arg.ExpirySweepTxid, arg.FinalizedWithdrawalTx, + arg.StaticAddressID, ) return err } const depositForOutpoint = `-- name: DepositForOutpoint :one SELECT - id, deposit_id, tx_hash, out_index, amount, confirmation_height, timeout_sweep_pk_script, expiry_sweep_txid, finalized_withdrawal_tx, swap_hash + d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, d.static_address_id, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id WHERE tx_hash = $1 AND @@ -117,9 +170,31 @@ type DepositForOutpointParams struct { OutIndex int32 } -func (q *Queries) DepositForOutpoint(ctx context.Context, arg DepositForOutpointParams) (Deposit, error) { +type DepositForOutpointRow struct { + ID int32 + DepositID []byte + TxHash []byte + OutIndex int32 + Amount int64 + ConfirmationHeight int64 + TimeoutSweepPkScript []byte + ExpirySweepTxid []byte + FinalizedWithdrawalTx sql.NullString + SwapHash []byte + StaticAddressID sql.NullInt32 + ClientPubkey []byte + ServerPubkey []byte + Expiry sql.NullInt32 + ClientKeyFamily sql.NullInt32 + ClientKeyIndex sql.NullInt32 + Pkscript []byte + ProtocolVersion sql.NullInt32 + InitiationHeight sql.NullInt32 +} + +func (q *Queries) DepositForOutpoint(ctx context.Context, arg DepositForOutpointParams) (DepositForOutpointRow, error) { row := q.db.QueryRowContext(ctx, depositForOutpoint, arg.TxHash, arg.OutIndex) - var i Deposit + var i DepositForOutpointRow err := row.Scan( &i.ID, &i.DepositID, @@ -131,22 +206,62 @@ func (q *Queries) DepositForOutpoint(ctx context.Context, arg DepositForOutpoint &i.ExpirySweepTxid, &i.FinalizedWithdrawalTx, &i.SwapHash, + &i.StaticAddressID, + &i.ClientPubkey, + &i.ServerPubkey, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.Pkscript, + &i.ProtocolVersion, + &i.InitiationHeight, ) return i, err } const getDeposit = `-- name: GetDeposit :one SELECT - id, deposit_id, tx_hash, out_index, amount, confirmation_height, timeout_sweep_pk_script, expiry_sweep_txid, finalized_withdrawal_tx, swap_hash + d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, d.static_address_id, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id WHERE deposit_id = $1 ` -func (q *Queries) GetDeposit(ctx context.Context, depositID []byte) (Deposit, error) { +type GetDepositRow struct { + ID int32 + DepositID []byte + TxHash []byte + OutIndex int32 + Amount int64 + ConfirmationHeight int64 + TimeoutSweepPkScript []byte + ExpirySweepTxid []byte + FinalizedWithdrawalTx sql.NullString + SwapHash []byte + StaticAddressID sql.NullInt32 + ClientPubkey []byte + ServerPubkey []byte + Expiry sql.NullInt32 + ClientKeyFamily sql.NullInt32 + ClientKeyIndex sql.NullInt32 + Pkscript []byte + ProtocolVersion sql.NullInt32 + InitiationHeight sql.NullInt32 +} + +func (q *Queries) GetDeposit(ctx context.Context, depositID []byte) (GetDepositRow, error) { row := q.db.QueryRowContext(ctx, getDeposit, depositID) - var i Deposit + var i GetDepositRow err := row.Scan( &i.ID, &i.DepositID, @@ -158,6 +273,15 @@ func (q *Queries) GetDeposit(ctx context.Context, depositID []byte) (Deposit, er &i.ExpirySweepTxid, &i.FinalizedWithdrawalTx, &i.SwapHash, + &i.StaticAddressID, + &i.ClientPubkey, + &i.ServerPubkey, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.Pkscript, + &i.ProtocolVersion, + &i.InitiationHeight, ) return i, err } diff --git a/loopdb/sqlc/static_address_loopin.sql.go b/loopdb/sqlc/static_address_loopin.sql.go index 319340168..8cb2aef6d 100644 --- a/loopdb/sqlc/static_address_loopin.sql.go +++ b/loopdb/sqlc/static_address_loopin.sql.go @@ -45,11 +45,20 @@ func (q *Queries) DepositIDsForSwapHash(ctx context.Context, swapHash []byte) ([ const depositsForSwapHash = `-- name: DepositsForSwapHash :many SELECT - d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, + d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, d.static_address_id, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height, u.update_state, u.update_timestamp FROM deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id LEFT JOIN deposit_updates u ON u.id = ( SELECT id @@ -73,6 +82,15 @@ type DepositsForSwapHashRow struct { ExpirySweepTxid []byte FinalizedWithdrawalTx sql.NullString SwapHash []byte + StaticAddressID sql.NullInt32 + ClientPubkey []byte + ServerPubkey []byte + Expiry sql.NullInt32 + ClientKeyFamily sql.NullInt32 + ClientKeyIndex sql.NullInt32 + Pkscript []byte + ProtocolVersion sql.NullInt32 + InitiationHeight sql.NullInt32 UpdateState sql.NullString UpdateTimestamp sql.NullTime } @@ -97,6 +115,15 @@ func (q *Queries) DepositsForSwapHash(ctx context.Context, swapHash []byte) ([]D &i.ExpirySweepTxid, &i.FinalizedWithdrawalTx, &i.SwapHash, + &i.StaticAddressID, + &i.ClientPubkey, + &i.ServerPubkey, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.Pkscript, + &i.ProtocolVersion, + &i.InitiationHeight, &i.UpdateState, &i.UpdateTimestamp, ); err != nil { diff --git a/loopdb/sqlc/static_addresses.sql.go b/loopdb/sqlc/static_addresses.sql.go index 054c07364..dbdb0e271 100644 --- a/loopdb/sqlc/static_addresses.sql.go +++ b/loopdb/sqlc/static_addresses.sql.go @@ -11,6 +11,7 @@ import ( const allStaticAddresses = `-- name: AllStaticAddresses :many SELECT id, client_pubkey, server_pubkey, expiry, client_key_family, client_key_index, pkscript, protocol_version, initiation_height FROM static_addresses +ORDER BY id ASC ` func (q *Queries) AllStaticAddresses(ctx context.Context) ([]StaticAddress, error) { @@ -93,6 +94,29 @@ func (q *Queries) CreateStaticAddress(ctx context.Context, arg CreateStaticAddre return err } +const getLegacyAddress = `-- name: GetLegacyAddress :one +SELECT id, client_pubkey, server_pubkey, expiry, client_key_family, client_key_index, pkscript, protocol_version, initiation_height FROM static_addresses +ORDER BY id ASC +LIMIT 1 +` + +func (q *Queries) GetLegacyAddress(ctx context.Context) (StaticAddress, error) { + row := q.db.QueryRowContext(ctx, getLegacyAddress) + var i StaticAddress + err := row.Scan( + &i.ID, + &i.ClientPubkey, + &i.ServerPubkey, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.Pkscript, + &i.ProtocolVersion, + &i.InitiationHeight, + ) + return i, err +} + const getStaticAddress = `-- name: GetStaticAddress :one SELECT id, client_pubkey, server_pubkey, expiry, client_key_family, client_key_index, pkscript, protocol_version, initiation_height FROM static_addresses WHERE pkscript=$1 @@ -114,3 +138,15 @@ func (q *Queries) GetStaticAddress(ctx context.Context, pkscript []byte) (Static ) return i, err } + +const getStaticAddressID = `-- name: GetStaticAddressID :one +SELECT id FROM static_addresses +WHERE pkscript=$1 +` + +func (q *Queries) GetStaticAddressID(ctx context.Context, pkscript []byte) (int32, error) { + row := q.db.QueryRowContext(ctx, getStaticAddressID, pkscript) + var id int32 + err := row.Scan(&id) + return id, err +} diff --git a/staticaddr/address/sql_store.go b/staticaddr/address/sql_store.go index 43257b81d..16f113c44 100644 --- a/staticaddr/address/sql_store.go +++ b/staticaddr/address/sql_store.go @@ -42,7 +42,14 @@ func (s *SqlStore) CreateStaticAddress(ctx context.Context, return s.baseDB.Queries.CreateStaticAddress(ctx, createArgs) } -// GetAllStaticAddresses returns all address known to the server. +// GetStaticAddressID retrieves the database ID for a static address script. +func (s *SqlStore) GetStaticAddressID(ctx context.Context, + pkScript []byte) (int32, error) { + + return s.baseDB.Queries.GetStaticAddressID(ctx, pkScript) +} + +// GetAllStaticAddresses returns all addresses known to the client. func (s *SqlStore) GetAllStaticAddresses(ctx context.Context) ( []*script.Parameters, error) { @@ -64,6 +71,18 @@ func (s *SqlStore) GetAllStaticAddresses(ctx context.Context) ( return result, nil } +// GetLegacyParameters returns the first static address created for this L402. +func (s *SqlStore) GetLegacyParameters(ctx context.Context) ( + *script.Parameters, error) { + + staticAddress, err := s.baseDB.Queries.GetLegacyAddress(ctx) + if err != nil { + return nil, err + } + + return s.toAddressParameters(staticAddress) +} + // toAddressParameters transforms a database representation of a static address // to an AddressParameters struct. func (s *SqlStore) toAddressParameters(row sqlc.StaticAddress) ( @@ -80,6 +99,7 @@ func (s *SqlStore) toAddressParameters(row sqlc.StaticAddress) ( } return &script.Parameters{ + ID: row.ID, ClientPubkey: clientPubkey, ServerPubkey: serverPubkey, PkScript: row.Pkscript, diff --git a/staticaddr/deposit/deposit.go b/staticaddr/deposit/deposit.go index d63cc4b74..8d5fa4636 100644 --- a/staticaddr/deposit/deposit.go +++ b/staticaddr/deposit/deposit.go @@ -9,6 +9,8 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" ) @@ -70,6 +72,11 @@ type Deposit struct { // FinalizedWithdrawalTx is the coop-signed withdrawal transaction. It // is republished on new block arrivals and on client restarts. FinalizedWithdrawalTx *wire.MsgTx + + // AddressParams are the static address parameters that produced this + // deposit's pkScript. Spending code must use these per-deposit + // parameters rather than assuming all deposits belong to one address. + AddressParams *script.Parameters } // IsInFinalState returns true if the deposit is final. @@ -152,6 +159,19 @@ func (d *Deposit) GetConfirmationHeightNoLock() int64 { return d.ConfirmationHeight } +// GetStaticAddressScript reconstructs the static address script for this +// deposit's matched address parameters. +func (d *Deposit) GetStaticAddressScript() (*script.StaticAddress, error) { + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address parameters") + } + + return script.NewStaticAddress( + input.MuSig2Version100RC2, int64(d.AddressParams.Expiry), + d.AddressParams.ClientPubkey, d.AddressParams.ServerPubkey, + ) +} + // GetRandomDepositID generates a random deposit ID. func GetRandomDepositID() (ID, error) { var id ID diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 61fc8e769..43b166bb4 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -376,6 +376,22 @@ func (m *Manager) createNewDeposit(ctx context.Context, if err != nil { return nil, err } + + addressParams, err := m.cfg.AddressManager. + GetStaticAddressParameters(ctx) + if err != nil { + return nil, fmt.Errorf("unable to get static address parameters: %w", + err) + } + if addressParams == nil { + return nil, fmt.Errorf("missing static address parameters for deposit %v", + utxo.OutPoint) + } + if addressParams.ID <= 0 { + return nil, fmt.Errorf("missing static address ID for deposit %v", + utxo.OutPoint) + } + deposit := &Deposit{ ID: id, state: Deposited, @@ -383,6 +399,7 @@ func (m *Manager) createNewDeposit(ctx context.Context, Value: utxo.Value, ConfirmationHeight: confirmationHeight, TimeOutSweepPkScript: timeoutSweepPkScript, + AddressParams: addressParams, } err = m.cfg.Store.CreateDeposit(ctx, deposit) diff --git a/staticaddr/deposit/manager_reconcile_test.go b/staticaddr/deposit/manager_reconcile_test.go index 15b2f0a69..b9e14bbaa 100644 --- a/staticaddr/deposit/manager_reconcile_test.go +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -42,7 +42,17 @@ func TestReconcileDepositsSerialized(t *testing.T) { ).Return([]*lnwallet.Utxo{utxo}, nil) mockAddressManager.On( "GetStaticAddressParameters", mock.Anything, - ).Return((*script.Parameters)(nil), errors.New("fsm init failed")) + ).Return(&script.Parameters{ + ID: 1, + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: utxo.PkScript, + ProtocolVersion: 999, + }, nil) + mockAddressManager.On( + "GetStaticAddress", mock.Anything, + ).Return((*script.StaticAddress)(nil), errors.New("fsm init failed")) mockStore := new(mockStore) var createCalls atomic.Int32 @@ -138,7 +148,17 @@ func TestReconcileConfirmedDepositUsesCurrentHeight(t *testing.T) { ).Return([]*lnwallet.Utxo{utxo}, nil) mockAddressManager.On( "GetStaticAddressParameters", mock.Anything, - ).Return((*script.Parameters)(nil), errors.New("fsm init failed")) + ).Return(&script.Parameters{ + ID: 1, + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: utxo.PkScript, + ProtocolVersion: 999, + }, nil) + mockAddressManager.On( + "GetStaticAddress", mock.Anything, + ).Return((*script.StaticAddress)(nil), errors.New("fsm init failed")) mockStore := new(mockStore) mockStore.On( @@ -146,6 +166,8 @@ func TestReconcileConfirmedDepositUsesCurrentHeight(t *testing.T) { ).Return(nil).Run(func(args mock.Arguments) { createdDeposit := args.Get(1).(*Deposit) require.EqualValues(t, 98, createdDeposit.ConfirmationHeight) + require.NotNil(t, createdDeposit.AddressParams) + require.EqualValues(t, 1, createdDeposit.AddressParams.ID) }) manager := NewManager(&ManagerConfig{ @@ -487,6 +509,7 @@ func TestReconcileDepositsReactivatesReappearedDeposit(t *testing.T) { mockAddressManager.On( "GetStaticAddressParameters", mock.Anything, ).Return(&script.Parameters{ + ID: 1, ProtocolVersion: version.ProtocolVersion_V0, }, nil) mockAddressManager.On( @@ -688,6 +711,7 @@ func TestReconcileReplacementDepositCreatesNewDeposit(t *testing.T) { mockAddressManager.On( "GetStaticAddressParameters", mock.Anything, ).Return(&script.Parameters{ + ID: 1, ProtocolVersion: version.ProtocolVersion_V0, }, nil) mockAddressManager.On( @@ -725,6 +749,8 @@ func TestReconcileReplacementDepositCreatesNewDeposit(t *testing.T) { require.Equal(t, newOutpoint, replacement.OutPoint) require.Equal(t, Deposited, replacement.GetState()) require.Zero(t, replacement.ConfirmationHeight) + require.NotNil(t, replacement.AddressParams) + require.EqualValues(t, 1, replacement.AddressParams.ID) require.Same(t, fsm, manager.activeDeposits[oldOutpoint]) require.NotSame(t, fsm, manager.activeDeposits[newOutpoint]) diff --git a/staticaddr/deposit/sql_store.go b/staticaddr/deposit/sql_store.go index a49550e5c..ea7ef82df 100644 --- a/staticaddr/deposit/sql_store.go +++ b/staticaddr/deposit/sql_store.go @@ -6,14 +6,19 @@ import ( "database/sql" "encoding/hex" "errors" + "fmt" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/loopdb/sqlc" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lntypes" ) @@ -49,6 +54,17 @@ func (s *SqlStore) CreateDeposit(ctx context.Context, deposit *Deposit) error { Amount: int64(deposit.Value), ConfirmationHeight: deposit.GetConfirmationHeight(), TimeoutSweepPkScript: deposit.TimeOutSweepPkScript, + StaticAddressID: sql.NullInt32{}, + } + if deposit.AddressParams != nil { + if deposit.AddressParams.ID <= 0 { + return fmt.Errorf("static address ID must be set") + } + + createArgs.StaticAddressID = sql.NullInt32{ + Int32: deposit.AddressParams.ID, + Valid: true, + } } updateArgs := sqlc.InsertDepositUpdateParams{ @@ -147,7 +163,9 @@ func (s *SqlStore) GetDeposit(ctx context.Context, id ID) (*Deposit, error) { return err } - deposit, err = ToDeposit(row, latestUpdate) + deposit, err = toDeposit( + depositRowFromGet(row), latestUpdate, + ) if err != nil { return err } @@ -193,7 +211,9 @@ func (s *SqlStore) DepositForOutpoint(ctx context.Context, return err } - deposit, err = ToDeposit(row, latestUpdate) + deposit, err = toDeposit( + depositRowFromOutpoint(row), latestUpdate, + ) if err != nil { return err } @@ -245,8 +265,105 @@ func (s *SqlStore) AllDeposits(ctx context.Context) ([]*Deposit, error) { return allDeposits, nil } -// ToDeposit converts an sql deposit to a deposit. -func ToDeposit(row sqlc.Deposit, lastUpdate sqlc.DepositUpdate) (*Deposit, +// ToDeposit converts an sql deposit row with joined static address metadata to +// a deposit. +func ToDeposit(row sqlc.AllDepositsRow, lastUpdate sqlc.DepositUpdate) (*Deposit, + error) { + + return toDeposit(depositRowFromAll(row), lastUpdate) +} + +type depositRow struct { + DepositID []byte + TxHash []byte + OutIndex int32 + Amount int64 + ConfirmationHeight int64 + TimeoutSweepPkScript []byte + ExpirySweepTxid []byte + FinalizedWithdrawalTx sql.NullString + SwapHash []byte + StaticAddressID sql.NullInt32 + ClientPubkey []byte + ServerPubkey []byte + Expiry sql.NullInt32 + ClientKeyFamily sql.NullInt32 + ClientKeyIndex sql.NullInt32 + Pkscript []byte + ProtocolVersion sql.NullInt32 + InitiationHeight sql.NullInt32 +} + +func depositRowFromAll(row sqlc.AllDepositsRow) depositRow { + return depositRow{ + DepositID: row.DepositID, + TxHash: row.TxHash, + OutIndex: row.OutIndex, + Amount: row.Amount, + ConfirmationHeight: row.ConfirmationHeight, + TimeoutSweepPkScript: row.TimeoutSweepPkScript, + ExpirySweepTxid: row.ExpirySweepTxid, + FinalizedWithdrawalTx: row.FinalizedWithdrawalTx, + SwapHash: row.SwapHash, + StaticAddressID: row.StaticAddressID, + ClientPubkey: row.ClientPubkey, + ServerPubkey: row.ServerPubkey, + Expiry: row.Expiry, + ClientKeyFamily: row.ClientKeyFamily, + ClientKeyIndex: row.ClientKeyIndex, + Pkscript: row.Pkscript, + ProtocolVersion: row.ProtocolVersion, + InitiationHeight: row.InitiationHeight, + } +} + +func depositRowFromGet(row sqlc.GetDepositRow) depositRow { + return depositRow{ + DepositID: row.DepositID, + TxHash: row.TxHash, + OutIndex: row.OutIndex, + Amount: row.Amount, + ConfirmationHeight: row.ConfirmationHeight, + TimeoutSweepPkScript: row.TimeoutSweepPkScript, + ExpirySweepTxid: row.ExpirySweepTxid, + FinalizedWithdrawalTx: row.FinalizedWithdrawalTx, + SwapHash: row.SwapHash, + StaticAddressID: row.StaticAddressID, + ClientPubkey: row.ClientPubkey, + ServerPubkey: row.ServerPubkey, + Expiry: row.Expiry, + ClientKeyFamily: row.ClientKeyFamily, + ClientKeyIndex: row.ClientKeyIndex, + Pkscript: row.Pkscript, + ProtocolVersion: row.ProtocolVersion, + InitiationHeight: row.InitiationHeight, + } +} + +func depositRowFromOutpoint(row sqlc.DepositForOutpointRow) depositRow { + return depositRow{ + DepositID: row.DepositID, + TxHash: row.TxHash, + OutIndex: row.OutIndex, + Amount: row.Amount, + ConfirmationHeight: row.ConfirmationHeight, + TimeoutSweepPkScript: row.TimeoutSweepPkScript, + ExpirySweepTxid: row.ExpirySweepTxid, + FinalizedWithdrawalTx: row.FinalizedWithdrawalTx, + SwapHash: row.SwapHash, + StaticAddressID: row.StaticAddressID, + ClientPubkey: row.ClientPubkey, + ServerPubkey: row.ServerPubkey, + Expiry: row.Expiry, + ClientKeyFamily: row.ClientKeyFamily, + ClientKeyIndex: row.ClientKeyIndex, + Pkscript: row.Pkscript, + ProtocolVersion: row.ProtocolVersion, + InitiationHeight: row.InitiationHeight, + } +} + +func toDeposit(row depositRow, lastUpdate sqlc.DepositUpdate) (*Deposit, error) { id := ID{} @@ -296,7 +413,7 @@ func ToDeposit(row sqlc.Deposit, lastUpdate sqlc.DepositUpdate) (*Deposit, swapHash = &hash } - return &Deposit{ + deposit := &Deposit{ ID: id, state: fsm.StateType(lastUpdate.UpdateState), OutPoint: wire.OutPoint{ @@ -309,5 +426,37 @@ func ToDeposit(row sqlc.Deposit, lastUpdate sqlc.DepositUpdate) (*Deposit, ExpirySweepTxid: expirySweepTxid, SwapHash: swapHash, FinalizedWithdrawalTx: finalizedWithdrawalTx, - }, nil + } + + if row.StaticAddressID.Valid { + clientPubkey, err := btcec.ParsePubKey(row.ClientPubkey) + if err != nil { + return nil, err + } + + serverPubkey, err := btcec.ParsePubKey(row.ServerPubkey) + if err != nil { + return nil, err + } + + deposit.AddressParams = &script.Parameters{ + ID: row.StaticAddressID.Int32, + ClientPubkey: clientPubkey, + ServerPubkey: serverPubkey, + Expiry: uint32(row.Expiry.Int32), + PkScript: row.Pkscript, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily( + row.ClientKeyFamily.Int32, + ), + Index: uint32(row.ClientKeyIndex.Int32), + }, + ProtocolVersion: version.AddressProtocolVersion( + row.ProtocolVersion.Int32, + ), + InitiationHeight: row.InitiationHeight.Int32, + } + } + + return deposit, nil } diff --git a/staticaddr/deposit/sql_store_test.go b/staticaddr/deposit/sql_store_test.go index 5656e386a..e6c2f814f 100644 --- a/staticaddr/deposit/sql_store_test.go +++ b/staticaddr/deposit/sql_store_test.go @@ -1,17 +1,121 @@ package deposit import ( + "context" "database/sql" "testing" "github.com/btcsuite/btcd/wire" "github.com/jackc/pgx/v5" "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/script" + "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lntypes" "github.com/stretchr/testify/require" ) +func TestCreateDepositRejectsUnpersistedAddress(t *testing.T) { + store := NewSqlStore(nil) + deposit := &Deposit{ + AddressParams: &script.Parameters{}, + } + + err := store.CreateDeposit(context.Background(), deposit) + require.ErrorContains(t, err, "static address ID must be set") +} + +// TestDepositAddressOwnershipRoundTrip asserts that every deposit read path +// restores the static address parameters referenced by the deposit row. +func TestDepositAddressOwnershipRoundTrip(t *testing.T) { + ctx := context.Background() + testDB := loopdb.NewTestDB(t) + defer testDB.Close() + + addressStore := address.NewSqlStore(testDB.BaseDB) + _, clientPubkey := test.CreateKey(1) + _, serverPubkey := test.CreateKey(2) + addressParams := &script.Parameters{ + ClientPubkey: clientPubkey, + ServerPubkey: serverPubkey, + Expiry: 144, + KeyLocator: keychain.KeyLocator{ + Family: 123, + Index: 456, + }, + PkScript: []byte{0x51, 0x20, 0x01}, + ProtocolVersion: version.ProtocolVersion_V0, + InitiationHeight: 789, + } + + err := addressStore.CreateStaticAddress(ctx, addressParams) + require.NoError(t, err) + addressParams.ID, err = addressStore.GetStaticAddressID( + ctx, addressParams.PkScript, + ) + require.NoError(t, err) + + depositID, err := GetRandomDepositID() + require.NoError(t, err) + + deposit := &Deposit{ + ID: depositID, + OutPoint: wire.OutPoint{ + Hash: wire.NewMsgTx(2).TxHash(), + Index: 3, + }, + Value: 100_000, + ConfirmationHeight: 321, + TimeOutSweepPkScript: []byte{0x00, 0x14, 0x02}, + AddressParams: addressParams, + state: Deposited, + } + + store := NewSqlStore(testDB.BaseDB) + require.NoError(t, store.CreateDeposit(ctx, deposit)) + + assertOwnership := func(t *testing.T, restored *Deposit) { + t.Helper() + require.NotNil(t, restored.AddressParams) + require.Equal(t, addressParams.ID, restored.AddressParams.ID) + require.Equal( + t, addressParams.ClientPubkey.SerializeCompressed(), + restored.AddressParams.ClientPubkey.SerializeCompressed(), + ) + require.Equal( + t, addressParams.ServerPubkey.SerializeCompressed(), + restored.AddressParams.ServerPubkey.SerializeCompressed(), + ) + require.Equal(t, addressParams.Expiry, + restored.AddressParams.Expiry) + require.Equal(t, addressParams.KeyLocator, + restored.AddressParams.KeyLocator) + require.Equal(t, addressParams.PkScript, + restored.AddressParams.PkScript) + require.Equal(t, addressParams.ProtocolVersion, + restored.AddressParams.ProtocolVersion) + require.Equal(t, addressParams.InitiationHeight, + restored.AddressParams.InitiationHeight) + } + + restored, err := store.GetDeposit(ctx, depositID) + require.NoError(t, err) + assertOwnership(t, restored) + + restored, err = store.DepositForOutpoint(ctx, deposit.OutPoint.String()) + require.NoError(t, err) + assertOwnership(t, restored) + + allDeposits, err := store.AllDeposits(ctx) + require.NoError(t, err) + require.Len(t, allDeposits, 1) + assertOwnership(t, allDeposits[0]) +} + func TestToDeposit(t *testing.T) { depositID, err := GetRandomDepositID() require.NoError(t, err) @@ -24,13 +128,13 @@ func TestToDeposit(t *testing.T) { tests := []struct { name string - row sqlc.Deposit + row sqlc.AllDepositsRow lastUpdate sqlc.DepositUpdate expectErr bool }{ { name: "fully valid data", - row: sqlc.Deposit{ + row: sqlc.AllDepositsRow{ DepositID: depositID[:], TxHash: txHash[:], Amount: 100000000, @@ -44,7 +148,7 @@ func TestToDeposit(t *testing.T) { }, { name: "fully valid data", - row: sqlc.Deposit{ + row: sqlc.AllDepositsRow{ DepositID: depositID[:], TxHash: txHash[:], Amount: 100000000, diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index 9dc2a084c..cd102850b 100644 --- a/staticaddr/loopin/sql_store.go +++ b/staticaddr/loopin/sql_store.go @@ -601,7 +601,7 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, return nil, err } - sqlcDeposit := sqlc.Deposit{ + sqlcDeposit := sqlc.AllDepositsRow{ DepositID: id[:], TxHash: d.TxHash, Amount: d.Amount, @@ -610,6 +610,16 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, TimeoutSweepPkScript: d.TimeoutSweepPkScript, ExpirySweepTxid: d.ExpirySweepTxid, FinalizedWithdrawalTx: d.FinalizedWithdrawalTx, + SwapHash: d.SwapHash, + StaticAddressID: d.StaticAddressID, + ClientPubkey: d.ClientPubkey, + ServerPubkey: d.ServerPubkey, + Expiry: d.Expiry, + ClientKeyFamily: d.ClientKeyFamily, + ClientKeyIndex: d.ClientKeyIndex, + Pkscript: d.Pkscript, + ProtocolVersion: d.ProtocolVersion, + InitiationHeight: d.InitiationHeight, } sqlcDepositUpdate := sqlc.DepositUpdate{ diff --git a/staticaddr/loopin/sql_store_test.go b/staticaddr/loopin/sql_store_test.go index c08d940f2..bdea7e5b4 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -10,13 +10,114 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/loopdb" + "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/test" "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lntypes" "github.com/stretchr/testify/require" ) +// TestLoopInDepositAddressOwnershipRoundTrip asserts that deposits restored as +// part of a loop-in retain the static address parameters needed for signing. +func TestLoopInDepositAddressOwnershipRoundTrip(t *testing.T) { + ctx := context.Background() + testDB := loopdb.NewTestDB(t) + defer testDB.Close() + + testClock := clock.NewTestClock(time.Now()) + depositStore := deposit.NewSqlStore(testDB.BaseDB) + loopInStore := NewSqlStore( + loopdb.NewTypedStore[Querier](testDB), testClock, + &chaincfg.RegressionNetParams, + ) + addressStore := address.NewSqlStore(testDB.BaseDB) + + _, addressClientPubkey := test.CreateKey(1) + _, addressServerPubkey := test.CreateKey(2) + addressParams := &script.Parameters{ + ClientPubkey: addressClientPubkey, + ServerPubkey: addressServerPubkey, + Expiry: 144, + KeyLocator: keychain.KeyLocator{ + Family: 123, + Index: 456, + }, + PkScript: []byte{0x51, 0x20, 0x02}, + ProtocolVersion: version.ProtocolVersion_V0, + InitiationHeight: 789, + } + require.NoError(t, addressStore.CreateStaticAddress(ctx, addressParams)) + + var err error + addressParams.ID, err = addressStore.GetStaticAddressID( + ctx, addressParams.PkScript, + ) + require.NoError(t, err) + + depositID, err := deposit.GetRandomDepositID() + require.NoError(t, err) + ownedDeposit := &deposit.Deposit{ + ID: depositID, + OutPoint: wire.OutPoint{ + Hash: wire.NewMsgTx(2).TxHash(), + Index: 3, + }, + Value: 100_000, + TimeOutSweepPkScript: []byte{0x00, 0x14, 0x03}, + AddressParams: addressParams, + } + ownedDeposit.SetState(deposit.Deposited) + require.NoError(t, depositStore.CreateDeposit(ctx, ownedDeposit)) + + ownedDeposit.SetState(deposit.LoopingIn) + require.NoError(t, depositStore.UpdateDeposit(ctx, ownedDeposit)) + + _, swapClientPubkey := test.CreateKey(3) + _, swapServerPubkey := test.CreateKey(4) + timeoutAddress, err := btcutil.DecodeAddress(P2wkhAddr, nil) + require.NoError(t, err) + + swapHash := lntypes.Hash{0x01, 0x02, 0x03, 0x04} + swap := &StaticAddressLoopIn{ + SwapHash: swapHash, + SwapPreimage: lntypes.Preimage{0x05, 0x06, 0x07, 0x08}, + DepositOutpoints: []string{ownedDeposit.OutPoint.String()}, + Deposits: []*deposit.Deposit{ownedDeposit}, + ClientPubkey: swapClientPubkey, + ServerPubkey: swapServerPubkey, + HtlcTimeoutSweepAddress: timeoutAddress, + } + swap.SetState(SignHtlcTx) + require.NoError(t, loopInStore.CreateLoopIn(ctx, swap)) + + restoredSwap, err := loopInStore.GetLoopInByHash(ctx, swapHash) + require.NoError(t, err) + require.Len(t, restoredSwap.Deposits, 1) + + restoredParams := restoredSwap.Deposits[0].AddressParams + require.NotNil(t, restoredParams) + require.Equal(t, addressParams.ID, restoredParams.ID) + require.Equal( + t, addressParams.ClientPubkey.SerializeCompressed(), + restoredParams.ClientPubkey.SerializeCompressed(), + ) + require.Equal( + t, addressParams.ServerPubkey.SerializeCompressed(), + restoredParams.ServerPubkey.SerializeCompressed(), + ) + require.Equal(t, addressParams.Expiry, restoredParams.Expiry) + require.Equal(t, addressParams.KeyLocator, restoredParams.KeyLocator) + require.Equal(t, addressParams.PkScript, restoredParams.PkScript) + require.Equal(t, addressParams.ProtocolVersion, + restoredParams.ProtocolVersion) + require.Equal(t, addressParams.InitiationHeight, + restoredParams.InitiationHeight) +} + // TestGetStaticAddressLoopInSwapsByStates tests that we can retrieve // StaticAddressLoopIn swaps by their states and that the deposits // associated with the swaps are correctly populated. diff --git a/staticaddr/script/parameters.go b/staticaddr/script/parameters.go index 89e2470b6..0fa1f73b4 100644 --- a/staticaddr/script/parameters.go +++ b/staticaddr/script/parameters.go @@ -9,6 +9,10 @@ import ( // Parameters holds all the necessary information for the 2-of-2 multisig // address. type Parameters struct { + // ID is the database primary key of the static address row. A zero value + // means the parameters have not been persisted yet. + ID int32 + // ClientPubkey is the client's pubkey for the static address. It is // used for the 2-of-2 funding output as well as for the client's // timeout path. From 3342e86f7bc746415ed17c4bf6b610c0c904522b Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 14:23:05 +0200 Subject: [PATCH 03/30] staticaddr/address: activate derived addresses Create receive and change addresses from locally derived client keys while reusing the server key and expiry from the legacy seed. Persist, import, and activate each script before returning it to callers. Rebuild the active address index on startup and serialize issuance without blocking address reads. Import only scripts missing from lnd, and accept duplicate-import errors only when they identify the expected Taproot output key. --- loopd/swapclient_server.go | 16 +- loopd/swapclient_server_staticaddr_test.go | 38 +- loopd/swapclient_server_test.go | 36 +- staticaddr/address/interface.go | 19 +- staticaddr/address/manager.go | 481 ++++++++++++++++----- staticaddr/address/manager_test.go | 435 ++++++++++++++++++- staticaddr/address/sql_store.go | 15 +- test/walletkit_mock.go | 72 ++- 8 files changed, 991 insertions(+), 121 deletions(-) diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 72c3d7dd7..77ee59c20 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1884,7 +1884,7 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context, // List all unspent utxos the wallet sees, regardless of the number of // confirmations. - staticAddress, utxos, err := s.staticAddressManager.ListUnspentRaw( + utxos, err := s.staticAddressManager.ListUnspentRaw( ctx, req.MinConfs, req.MaxConfs, ) if err != nil { @@ -1935,6 +1935,20 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context, continue } + params := s.staticAddressManager.GetParameters(u.PkScript) + if params == nil { + return nil, fmt.Errorf("missing static address "+ + "parameters for %v", u.OutPoint) + } + + staticAddress, err := s.staticAddressManager.GetTaprootAddress( + params.ClientPubkey, params.ServerPubkey, + int64(params.Expiry), + ) + if err != nil { + return nil, err + } + utxo := &looprpc.Utxo{ StaticAddress: staticAddress.String(), AmountSat: int64(u.Value), diff --git a/loopd/swapclient_server_staticaddr_test.go b/loopd/swapclient_server_staticaddr_test.go index bb4cc01ca..9478109bb 100644 --- a/loopd/swapclient_server_staticaddr_test.go +++ b/loopd/swapclient_server_staticaddr_test.go @@ -62,12 +62,44 @@ func (s *staticAddrDepositStore) AllDeposits(context.Context) ( return s.allDeposits, nil } -type staticAddrTestAddressManager struct{} +type staticAddrTestAddressManager struct { + params *address.Parameters +} + +func newStaticAddrTestAddressManager() *staticAddrTestAddressManager { + _, client := mock_lnd.CreateKey(1) + _, server := mock_lnd.CreateKey(2) + + return &staticAddrTestAddressManager{ + params: &address.Parameters{ + ID: 1, + ClientPubkey: client, + ServerPubkey: server, + Expiry: 10, + PkScript: []byte("pkscript"), + }, + } +} func (s *staticAddrTestAddressManager) GetStaticAddressParameters( context.Context) (*script.Parameters, error) { - return nil, nil + return s.params, nil +} + +func (s *staticAddrTestAddressManager) GetStaticAddressID( + context.Context, []byte) (int32, error) { + + return s.params.ID, nil +} + +func (s *staticAddrTestAddressManager) GetParameters( + pkScript []byte) *address.Parameters { + + params := *s.params + params.PkScript = pkScript + + return ¶ms } func (s *staticAddrTestAddressManager) GetStaticAddress( @@ -99,7 +131,7 @@ func newTestDepositManager( } return deposit.NewManager(&deposit.ManagerConfig{ - AddressManager: &staticAddrTestAddressManager{}, + AddressManager: newStaticAddrTestAddressManager(), Store: &staticAddrDepositStore{ allDeposits: deposits, byOutpoint: byOutpoint, diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index 1171389c9..725340cfe 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -1,7 +1,9 @@ package loopd import ( + "bytes" "context" + "database/sql" "fmt" "os" "testing" @@ -1883,10 +1885,25 @@ type mockAddressStore struct { func (s *mockAddressStore) CreateStaticAddress(_ context.Context, p *script.Parameters) error { + if p.ID == 0 { + p.ID = int32(len(s.params) + 1) + } s.params = append(s.params, p) return nil } +func (s *mockAddressStore) GetStaticAddressID(_ context.Context, + pkScript []byte) (int32, error) { + + for _, p := range s.params { + if bytes.Equal(p.PkScript, pkScript) { + return p.ID, nil + } + } + + return 0, sql.ErrNoRows +} + func (s *mockAddressStore) GetStaticAddress(_ context.Context, _ []byte) ( *script.Parameters, error) { @@ -1903,6 +1920,16 @@ func (s *mockAddressStore) GetAllStaticAddresses(_ context.Context) ( return s.params, nil } +func (s *mockAddressStore) GetLegacyParameters(_ context.Context) ( + *address.Parameters, error) { + + if len(s.params) == 0 { + return nil, sql.ErrNoRows + } + + return s.params[0], nil +} + // mockDepositStore implements deposit.Store minimally for DepositsForOutpoints. type mockDepositStore struct { byOutpoint map[string]*deposit.Deposit @@ -2038,7 +2065,12 @@ func TestListUnspentDeposits(t *testing.T) { // Prepare a single static address parameter set. _, client := mock_lnd.CreateKey(1) _, server := mock_lnd.CreateKey(2) - pkScript := []byte("pkscript") + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, 10, client, server, + ) + require.NoError(t, err) + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) addrParams := &script.Parameters{ ClientPubkey: client, ServerPubkey: server, @@ -2056,6 +2088,8 @@ func TestListUnspentDeposits(t *testing.T) { // ChainNotifier and AddressClient are not needed for this test. }, 1) require.NoError(t, err) + _, err = addrMgr.EnsureStaticAddressSeed(ctx) + require.NoError(t, err) // Construct several UTXOs with different confirmation counts. makeUtxo := func(idx uint32, confs int64) *lnwallet.Utxo { diff --git a/staticaddr/address/interface.go b/staticaddr/address/interface.go index 63b6cf7c1..8a9805a6b 100644 --- a/staticaddr/address/interface.go +++ b/staticaddr/address/interface.go @@ -6,15 +6,26 @@ import ( "github.com/lightninglabs/loop/staticaddr/script" ) +// Parameters aliases the script-level static address parameters for callers +// that interact with the address manager API. +type Parameters = script.Parameters + // Store is the database interface that is used to store and retrieve // static addresses. type Store interface { // CreateStaticAddress inserts a new static address with its parameters // into the store. - CreateStaticAddress(ctx context.Context, - addrParams *script.Parameters) error + CreateStaticAddress(ctx context.Context, addrParams *Parameters) error + + // GetStaticAddressID retrieves the static address row ID for the + // address script. + GetStaticAddressID(ctx context.Context, pkScript []byte) (int32, error) // GetAllStaticAddresses retrieves all static addresses from the store. - GetAllStaticAddresses(ctx context.Context) ([]*script.Parameters, - error) + GetAllStaticAddresses(ctx context.Context) ([]*Parameters, error) + + // GetLegacyParameters retrieves the first static address created for the + // L402. This is the immutable legacy/root address that anchors existing + // single-address deposits. + GetLegacyParameters(ctx context.Context) (*Parameters, error) } diff --git a/staticaddr/address/manager.go b/staticaddr/address/manager.go index 322eed739..01a9507d8 100644 --- a/staticaddr/address/manager.go +++ b/staticaddr/address/manager.go @@ -1,9 +1,11 @@ package address import ( - "bytes" "context" + "database/sql" + "errors" "fmt" + "strings" "sync" "sync/atomic" @@ -11,7 +13,9 @@ import ( "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/version" @@ -19,6 +23,7 @@ import ( staticaddressrpc "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lnwallet" ) @@ -29,6 +34,12 @@ const ( maxStaticAddressCSVExpiry = uint32(200 * 144) ) +var ( + // ErrNoStaticAddress is returned when no static address parameters are + // present in the store. + ErrNoStaticAddress = errors.New("no static address parameters found") +) + // ManagerConfig holds the configuration for the address manager. type ManagerConfig struct { // AddressClient is the client that communicates with the loop server @@ -59,9 +70,16 @@ type ManagerConfig struct { type Manager struct { sync.Mutex - cfg *ManagerConfig + cfg *ManagerConfig + issuanceMu sync.Mutex currentHeight atomic.Int32 + + // activeStaticAddresses is the runtime index used to match wallet UTXOs + // to locally known static address parameters. The DB remains the + // durable source of truth; this map is rebuilt from the DB on startup + // and updated after successful address issuance. + activeStaticAddresses map[string]*Parameters } // NewManager creates a new address manager. @@ -72,7 +90,8 @@ func NewManager(cfg *ManagerConfig, currentHeight int32) (*Manager, error) { } m := &Manager{ - cfg: cfg, + cfg: cfg, + activeStaticAddresses: make(map[string]*Parameters), } m.currentHeight.Store(currentHeight) @@ -88,6 +107,11 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { return err } + err = m.loadActiveAddresses(ctx) + if err != nil { + return err + } + // Communicate to the caller that the address manager has completed its // initialization. close(initChan) @@ -107,54 +131,195 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { } } -// NewAddress creates a new static address with the server or returns an -// existing one. +// loadActiveAddresses rebuilds the runtime address map from the durable DB +// state and repairs only wallet watches that are actually missing. +func (m *Manager) loadActiveAddresses(ctx context.Context) error { + params, err := m.cfg.Store.GetAllStaticAddresses(ctx) + if err != nil { + return err + } + + return m.activateAddresses(ctx, params) +} + +// activateAddresses adds persisted addresses to the runtime map. A single +// wallet read replaces the previous one-write-RPC-per-address startup path. +func (m *Manager) activateAddresses(ctx context.Context, + params []*Parameters) error { + + active := make(map[string]*Parameters, len(params)) + if len(params) == 0 { + m.Lock() + m.activeStaticAddresses = active + m.Unlock() + + return nil + } + + walletScripts, err := m.walletAddressScripts(ctx) + if err != nil { + return err + } + + for _, param := range params { + if param == nil { + return fmt.Errorf("missing static address parameters") + } + + if _, ok := walletScripts[string(param.PkScript)]; !ok { + staticAddress, err := staticAddressFromParams(param) + if err != nil { + return err + } + + err = m.importAddressTapscript(ctx, staticAddress) + if err != nil { + return err + } + } + + active[string(param.PkScript)] = param + } + + m.Lock() + m.activeStaticAddresses = active + m.Unlock() + + return nil +} + +// walletAddressScripts returns all scripts currently watched by lnd's +// imported account. ListAddresses is available at Loop's minimum supported lnd +// version and lets startup reconcile every static address with one read RPC. +func (m *Manager) walletAddressScripts(ctx context.Context) ( + map[string]struct{}, error) { + + rpcCtx, rpcTimeout, walletClient := + m.cfg.WalletKit.RawClientWithMacAuth(ctx) + if walletClient == nil { + return nil, fmt.Errorf("missing raw wallet kit client") + } + + if rpcTimeout > 0 { + var cancel context.CancelFunc + rpcCtx, cancel = context.WithTimeout(rpcCtx, rpcTimeout) + defer cancel() + } + + resp, err := walletClient.ListAddresses( + rpcCtx, &walletrpc.ListAddressesRequest{ + AccountName: waddrmgr.ImportedAddrAccountName, + }, + ) + if err != nil { + return nil, fmt.Errorf("list imported wallet addresses: %w", err) + } + + scripts := make(map[string]struct{}) + for _, account := range resp.GetAccountWithAddresses() { + for _, property := range account.GetAddresses() { + addr, err := btcutil.DecodeAddress( + property.GetAddress(), m.cfg.ChainParams, + ) + if err != nil { + return nil, fmt.Errorf("decode imported wallet "+ + "address: %w", err) + } + if !addr.IsForNet(m.cfg.ChainParams) { + return nil, fmt.Errorf("imported wallet address is for " + + "the wrong network") + } + + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + return nil, fmt.Errorf("derive imported wallet "+ + "address script: %w", err) + } + + scripts[string(pkScript)] = struct{}{} + } + } + + return scripts, nil +} + +// NewAddress creates the next externally visible receive static address. +// +// The first call also makes sure the legacy/root static address seed exists, +// because receive and change addresses are derived from the server pubkey and +// expiry returned for that seed. func (m *Manager) NewAddress(ctx context.Context) (*btcutil.AddressTaproot, int64, error) { - // If there's already a static address in the database, we can return - // it. - m.Lock() - addresses, err := m.cfg.Store.GetAllStaticAddresses(ctx) + params, err := m.NewReceiveAddress(ctx) if err != nil { - m.Unlock() + return nil, 0, err + } + address, err := m.GetTaprootAddress( + params.ClientPubkey, params.ServerPubkey, int64(params.Expiry), + ) + if err != nil { return nil, 0, err } - if len(addresses) > 0 { - clientPubKey := addresses[0].ClientPubkey - serverPubKey := addresses[0].ServerPubkey - expiry := int64(addresses[0].Expiry) - defer m.Unlock() + return address, int64(params.Expiry), nil +} - address, err := m.GetTaprootAddress( - clientPubKey, serverPubKey, expiry, - ) +// EnsureStaticAddressSeed loads or creates the legacy/root static address +// parameters. The root address is the only address that requires a Nautilus +// ServerNewAddress call; all receive/change addresses derive client keys +// locally and reuse this server pubkey/expiry seed. +func (m *Manager) EnsureStaticAddressSeed(ctx context.Context) (*Parameters, + error) { + + m.Lock() + seed := m.legacyParameters() + m.Unlock() + if seed != nil { + return seed, nil + } + + m.issuanceMu.Lock() + defer m.issuanceMu.Unlock() + + // Another caller may have created the seed while we were waiting for the + // issuance lock. + m.Lock() + seed = m.legacyParameters() + m.Unlock() + if seed != nil { + return seed, nil + } + + addresses, err := m.cfg.Store.GetAllStaticAddresses(ctx) + if err != nil { + return nil, err + } + if len(addresses) > 0 { + err = m.activateAddresses(ctx, addresses) if err != nil { - return nil, 0, err + return nil, err } - return address, expiry, nil + return addresses[0], nil } - m.Unlock() - // We are fetching a new L402 token from the server. There is one static - // address per L402 token allowed. + // We are fetching a new L402 token from the server. The returned server + // key/expiry is the static address seed for all future client-derived + // addresses for this L402. err = m.cfg.FetchL402(ctx) if err != nil { - return nil, 0, err + return nil, err } clientPubKey, err := m.cfg.WalletKit.DeriveNextKey( ctx, swap.StaticAddressKeyFamily, ) if err != nil { - return nil, 0, err + return nil, err } - // Send our clientPubKey to the server and wait for the server to - // respond with he serverPubKey and the static address CSV expiry. protocolVersion := version.CurrentRPCProtocolVersion() resp, err := m.cfg.AddressClient.ServerNewAddress( ctx, &staticaddressrpc.ServerNewAddressRequest{ @@ -163,78 +328,124 @@ func (m *Manager) NewAddress(ctx context.Context) (*btcutil.AddressTaproot, }, ) if err != nil { - return nil, 0, err + return nil, err } if resp == nil { - return nil, 0, fmt.Errorf("missing server new address response") + return nil, fmt.Errorf("missing server new address response") } serverParams := resp.GetParams() if err := validateServerAddressParams(serverParams); err != nil { - return nil, 0, err + return nil, err } serverPubKey, err := btcec.ParsePubKey(serverParams.GetServerKey()) if err != nil { - return nil, 0, err + return nil, err + } + + return m.createAddressFromKey( + ctx, clientPubKey, serverPubKey, serverParams.Expiry, + version.AddressProtocolVersion(protocolVersion), + ) +} + +// NewReceiveAddress derives, stores, imports and activates the next receive +// family static address. It is used by `loop static new`. +func (m *Manager) NewReceiveAddress(ctx context.Context) (*Parameters, error) { + seed, err := m.EnsureStaticAddressSeed(ctx) + if err != nil { + return nil, err + } + + return m.newDerivedAddress(ctx, seed, swap.StaticMultiAddressKeyFamily) +} + +// NewChangeAddress derives, stores, imports and activates the next change +// family static address. Swap and withdrawal code calls this before submitting +// requests that require change. +func (m *Manager) NewChangeAddress(ctx context.Context) (*Parameters, error) { + seed, err := m.EnsureStaticAddressSeed(ctx) + if err != nil { + return nil, err } + return m.newDerivedAddress(ctx, seed, swap.StaticAddressChangeKeyFamily) +} + +func (m *Manager) newDerivedAddress(ctx context.Context, seed *Parameters, + keyFamily int32) (*Parameters, error) { + + m.issuanceMu.Lock() + defer m.issuanceMu.Unlock() + + clientPubKey, err := m.cfg.WalletKit.DeriveNextKey(ctx, keyFamily) + if err != nil { + return nil, err + } + + return m.createAddressFromKey( + ctx, clientPubKey, seed.ServerPubkey, seed.Expiry, + seed.ProtocolVersion, + ) +} + +func (m *Manager) createAddressFromKey(ctx context.Context, + clientPubKey *keychain.KeyDescriptor, serverPubKey *btcec.PublicKey, + expiry uint32, protocolVersion version.AddressProtocolVersion) ( + *Parameters, error) { + staticAddress, err := script.NewStaticAddress( - input.MuSig2Version100RC2, int64(serverParams.Expiry), - clientPubKey.PubKey, serverPubKey, + input.MuSig2Version100RC2, int64(expiry), clientPubKey.PubKey, + serverPubKey, ) if err != nil { - return nil, 0, err + return nil, err } pkScript, err := staticAddress.StaticAddressScript() if err != nil { - return nil, 0, err + return nil, err } - // Create the static address from the parameters the server provided and - // store all parameters in the database. - addrParams := &script.Parameters{ + addrParams := &Parameters{ ClientPubkey: clientPubKey.PubKey, ServerPubkey: serverPubKey, PkScript: pkScript, - Expiry: serverParams.Expiry, + Expiry: expiry, KeyLocator: keychain.KeyLocator{ Family: clientPubKey.Family, Index: clientPubKey.Index, }, - ProtocolVersion: version.AddressProtocolVersion( - protocolVersion, - ), + ProtocolVersion: protocolVersion, InitiationHeight: m.currentHeight.Load(), } + + // Persist the address before importing it into lnd. In particular, the + // server has already committed a root seed at this point, so retaining the + // client key locator lets a later retry repair a failed wallet import + // instead of deriving a different root key. err = m.cfg.Store.CreateStaticAddress(ctx, addrParams) if err != nil { - return nil, 0, err + return nil, err } - // Import the static address tapscript into our lnd wallet, so we can - // track unspent outputs of it. - tapScript := input.TapscriptFullTree( - staticAddress.InternalPubKey, *staticAddress.TimeoutLeaf, - ) - addr, err := m.cfg.WalletKit.ImportTaprootScript(ctx, tapScript) + addrParams.ID, err = m.cfg.Store.GetStaticAddressID(ctx, pkScript) if err != nil { - return nil, 0, err + return nil, err } - log.Infof("Imported static address taproot script to lnd wallet: %v", - addr) - - address, err := m.GetTaprootAddress( - clientPubKey.PubKey, serverPubKey, int64(serverParams.Expiry), - ) + err = m.importAddressTapscript(ctx, staticAddress) if err != nil { - return nil, 0, err + return nil, err } - return address, int64(serverParams.Expiry), nil + m.Lock() + m.activeStaticAddresses[string(pkScript)] = addrParams + m.Unlock() + + return addrParams, nil } // validateServerAddressParams validates the server-controlled static address @@ -272,6 +483,65 @@ func validateServerAddressParams( return nil } +func (m *Manager) importAddressTapscript(ctx context.Context, + staticAddress *script.StaticAddress) error { + + // Import the static address tapscript into our lnd wallet, so we can + // track unspent outputs of it. + tapScript := input.TapscriptFullTree( + staticAddress.InternalPubKey, *staticAddress.TimeoutLeaf, + ) + addr, err := m.cfg.WalletKit.ImportTaprootScript(ctx, tapScript) + if err != nil { + // Importing into an lnd instance that already knows the script is + // expected on restart. Lnd currently returns this as an untyped gRPC + // error, so also match the expected output key. + duplicateErr := fmt.Sprintf( + "address for script hash/key %x already exists", + schnorr.SerializePubKey(staticAddress.TaprootKey), + ) + if strings.Contains(err.Error(), duplicateErr) { + log.Infof("Static address tapscript already imported") + return nil + } + + return err + } + + log.Infof("Imported static address taproot script to lnd wallet: %v", + addr) + + return nil +} + +func staticAddressFromParams(params *Parameters) (*script.StaticAddress, + error) { + + if params == nil { + return nil, fmt.Errorf("missing static address parameters") + } + + return script.NewStaticAddress( + input.MuSig2Version100RC2, int64(params.Expiry), + params.ClientPubkey, params.ServerPubkey, + ) +} + +func (m *Manager) legacyParameters() *Parameters { + var legacy *Parameters + for _, params := range m.activeStaticAddresses { + if params == nil { + continue + } + + if legacy == nil || params.ID < legacy.ID { + legacy = params + } + } + + return legacy +} + // GetTaprootAddress returns a taproot address for the given client and server // public keys and expiry. func (m *Manager) GetTaprootAddress(clientPubkey, serverPubkey *btcec.PublicKey, @@ -292,21 +562,17 @@ func (m *Manager) GetTaprootAddress(clientPubkey, serverPubkey *btcec.PublicKey, // ListUnspentRaw returns a list of utxos at the static address. func (m *Manager) ListUnspentRaw(ctx context.Context, minConfs, - maxConfs int32) (*btcutil.AddressTaproot, []*lnwallet.Utxo, error) { - - addresses, err := m.cfg.Store.GetAllStaticAddresses(ctx) - switch { - case err != nil: - return nil, nil, err - - case len(addresses) == 0: - return nil, nil, nil + maxConfs int32) ([]*lnwallet.Utxo, error) { - case len(addresses) > 1: - return nil, nil, fmt.Errorf("more than one address found") + m.Lock() + active := make(map[string]struct{}, len(m.activeStaticAddresses)) + for pkScript := range m.activeStaticAddresses { + active[pkScript] = struct{}{} + } + m.Unlock() + if len(active) == 0 { + return nil, nil } - - staticAddress := addresses[0] // List all unspent utxos the wallet sees, regardless of the number of // confirmations. @@ -314,43 +580,36 @@ func (m *Manager) ListUnspentRaw(ctx context.Context, minConfs, ctx, minConfs, maxConfs, ) if err != nil { - return nil, nil, err + return nil, err } - // Filter the list of lnd's unspent utxos for the pkScript of our static - // address. + // Filter the list of lnd's unspent utxos for any locally active static + // address script. var filteredUtxos []*lnwallet.Utxo for _, utxo := range utxos { - if bytes.Equal(utxo.PkScript, staticAddress.PkScript) { + if _, ok := active[string(utxo.PkScript)]; ok { filteredUtxos = append(filteredUtxos, utxo) } } - taprootAddress, err := m.GetTaprootAddress( - staticAddress.ClientPubkey, staticAddress.ServerPubkey, - int64(staticAddress.Expiry), - ) - if err != nil { - return nil, nil, err - } - - return taprootAddress, filteredUtxos, nil + return filteredUtxos, nil } -// GetStaticAddressParameters returns the parameters of the static address. +// GetStaticAddressParameters returns the legacy/root static-address +// parameters. func (m *Manager) GetStaticAddressParameters(ctx context.Context) ( *script.Parameters, error) { - params, err := m.cfg.Store.GetAllStaticAddresses(ctx) + params, err := m.GetLegacyParameters(ctx) if err != nil { return nil, err } - if len(params) == 0 { - return nil, fmt.Errorf("no static address parameters found") + if params == nil { + return nil, ErrNoStaticAddress } - return params[0], nil + return params, nil } // GetStaticAddress returns a taproot address for the given client and server @@ -363,25 +622,47 @@ func (m *Manager) GetStaticAddress(ctx context.Context) (*script.StaticAddress, return nil, err } - address, err := script.NewStaticAddress( - input.MuSig2Version100RC2, int64(params.Expiry), - params.ClientPubkey, params.ServerPubkey, - ) - if err != nil { - return nil, err - } - - return address, nil + return staticAddressFromParams(params) } // ListUnspent returns a list of utxos at the static address. func (m *Manager) ListUnspent(ctx context.Context, minConfs, maxConfs int32) ([]*lnwallet.Utxo, error) { - _, utxos, err := m.ListUnspentRaw(ctx, minConfs, maxConfs) + return m.ListUnspentRaw(ctx, minConfs, maxConfs) +} + +// GetLegacyParameters returns the legacy/root static address parameters. +func (m *Manager) GetLegacyParameters(ctx context.Context) (*Parameters, + error) { + + params, err := m.cfg.Store.GetLegacyParameters(ctx) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } if err != nil { return nil, err } - return utxos, nil + return params, nil +} + +// GetParameters returns active static address parameters for a pkScript. +func (m *Manager) GetParameters(pkScript []byte) *Parameters { + m.Lock() + defer m.Unlock() + + return m.activeStaticAddresses[string(pkScript)] +} + +// GetStaticAddressID returns the database row ID for a static address script. +func (m *Manager) GetStaticAddressID(ctx context.Context, + pkScript []byte) (int32, error) { + + return m.cfg.Store.GetStaticAddressID(ctx, pkScript) +} + +// GetAllAddresses returns all persisted static address parameters. +func (m *Manager) GetAllAddresses(ctx context.Context) ([]*Parameters, error) { + return m.cfg.Store.GetAllStaticAddresses(ctx) } diff --git a/staticaddr/address/manager_test.go b/staticaddr/address/manager_test.go index b7bbf79ae..2a4e0ce32 100644 --- a/staticaddr/address/manager_test.go +++ b/staticaddr/address/manager_test.go @@ -2,13 +2,21 @@ package address import ( "context" + "encoding/binary" "encoding/hex" + "errors" + "fmt" "testing" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swap" @@ -16,6 +24,7 @@ import ( "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "google.golang.org/grpc" @@ -33,6 +42,79 @@ type mockStaticAddressClient struct { mock.Mock } +type blockingImportWallet struct { + lndclient.WalletKitClient + + started chan struct{} + release chan struct{} + result error +} + +type listAddressesClient struct { + walletrpc.WalletKitClient + + response *walletrpc.ListAddressesResponse + err error + calls int +} + +func (c *listAddressesClient) ListAddresses(_ context.Context, + _ *walletrpc.ListAddressesRequest, _ ...grpc.CallOption) ( + *walletrpc.ListAddressesResponse, error) { + + c.calls++ + return c.response, c.err +} + +type addressListWallet struct { + lndclient.WalletKitClient + + rawClient *listAddressesClient + imports int +} + +func (w *addressListWallet) RawClientWithMacAuth(ctx context.Context) ( + context.Context, time.Duration, walletrpc.WalletKitClient) { + + return ctx, time.Second, w.rawClient +} + +func (w *addressListWallet) ImportTaprootScript(_ context.Context, + _ *waddrmgr.Tapscript) (btcutil.Address, error) { + + w.imports++ + return nil, nil +} + +type addressListStore struct { + Store + + addresses []*Parameters +} + +func (s *addressListStore) GetAllStaticAddresses(context.Context) ( + []*Parameters, error) { + + return s.addresses, nil +} + +func (w *blockingImportWallet) ImportTaprootScript(ctx context.Context, + _ *waddrmgr.Tapscript) (btcutil.Address, error) { + + if w.started != nil { + close(w.started) + } + if w.release != nil { + select { + case <-w.release: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + return nil, w.result +} + func (m *mockStaticAddressClient) ServerStaticAddressLoopIn(ctx context.Context, in *swapserverrpc.ServerStaticAddressLoopInRequest, opts ...grpc.CallOption) ( @@ -132,6 +214,355 @@ func TestManager(t *testing.T) { // The expiry has to match. require.EqualValues(t, defaultExpiry, expiry) + + storedParams, err := testContext.manager.GetStaticAddressParameters(ctxb) + require.NoError(t, err) + require.EqualValues( + t, swap.StaticAddressKeyFamily, storedParams.KeyLocator.Family, + ) + + addresses, err := testContext.manager.GetAllAddresses(ctxb) + require.NoError(t, err) + require.Len(t, addresses, 2) + require.EqualValues( + t, swap.StaticMultiAddressKeyFamily, + addresses[1].KeyLocator.Family, + ) +} + +func TestAddressIssuanceDoesNotBlockAddressReads(t *testing.T) { + testContext := NewAddressManagerTestContext(t) + seed, err := testContext.manager.EnsureStaticAddressSeed(t.Context()) + require.NoError(t, err) + + started := make(chan struct{}) + release := make(chan struct{}) + testContext.manager.cfg.WalletKit = &blockingImportWallet{ + WalletKitClient: testContext.mockLnd.WalletKit, + started: started, + release: release, + } + + issuanceDone := make(chan error, 1) + go func() { + _, err := testContext.manager.NewReceiveAddress(t.Context()) + issuanceDone <- err + }() + + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("address import did not start") + } + + lookupDone := make(chan *Parameters, 1) + go func() { + lookupDone <- testContext.manager.GetParameters(seed.PkScript) + }() + + select { + case params := <-lookupDone: + require.Same(t, seed, params) + case <-time.After(time.Second): + t.Fatal("address lookup blocked on address issuance") + } + + close(release) + require.NoError(t, <-issuanceDone) +} + +func TestSeedImportFailureRetainsDerivedKey(t *testing.T) { + testContext := NewAddressManagerTestContext(t) + importErr := errors.New("wallet import failed") + originalWallet := testContext.manager.cfg.WalletKit + testContext.manager.cfg.WalletKit = &blockingImportWallet{ + WalletKitClient: originalWallet, + result: importErr, + } + + _, err := testContext.manager.EnsureStaticAddressSeed(t.Context()) + require.ErrorIs(t, err, importErr) + + addresses, err := testContext.manager.GetAllAddresses(t.Context()) + require.NoError(t, err) + require.Len(t, addresses, 1) + persisted := addresses[0] + require.EqualValues( + t, swap.StaticAddressKeyFamily, persisted.KeyLocator.Family, + ) + + testContext.manager.cfg.WalletKit = originalWallet + seed, err := testContext.manager.EnsureStaticAddressSeed(t.Context()) + require.NoError(t, err) + require.Equal(t, persisted.KeyLocator, seed.KeyLocator) + require.True(t, persisted.ClientPubkey.IsEqual(seed.ClientPubkey)) + testContext.mockStaticAddressClient.AssertNumberOfCalls( + t, "ServerNewAddress", 1, + ) +} + +func TestLoadActiveAddressesUsesSingleWalletRead(t *testing.T) { + const addressCount = 1000 + + params := make([]*Parameters, 0, addressCount) + properties := make([]*walletrpc.AddressProperty, 0, addressCount) + for i := range addressCount { + keyBytes := make([]byte, btcec.PrivKeyBytesLen) + binary.BigEndian.PutUint32( + keyBytes[btcec.PrivKeyBytesLen-4:], uint32(i+1), + ) + _, pubKey := btcec.PrivKeyFromBytes(keyBytes) + addr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(pubKey), + &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + params = append(params, &Parameters{PkScript: pkScript}) + properties = append(properties, &walletrpc.AddressProperty{ + Address: addr.EncodeAddress(), + }) + } + + rawClient := &listAddressesClient{ + response: &walletrpc.ListAddressesResponse{ + AccountWithAddresses: []*walletrpc.AccountWithAddresses{{ + Name: waddrmgr.ImportedAddrAccountName, + Addresses: properties, + }}, + }, + } + wallet := &addressListWallet{rawClient: rawClient} + manager, err := NewManager(&ManagerConfig{ + Store: &addressListStore{addresses: params}, + WalletKit: wallet, + ChainParams: &chaincfg.RegressionNetParams, + }, 1) + require.NoError(t, err) + + require.NoError(t, manager.loadActiveAddresses(t.Context())) + require.Equal(t, 1, rawClient.calls) + require.Zero(t, wallet.imports) + require.Len(t, manager.activeStaticAddresses, addressCount) +} + +func BenchmarkLoadActiveAddresses(b *testing.B) { + for _, addressCount := range []int{0, 100, 1000} { + b.Run(fmt.Sprintf("addresses_%d", addressCount), func(b *testing.B) { + params := make([]*Parameters, 0, addressCount) + properties := make( + []*walletrpc.AddressProperty, 0, addressCount, + ) + for i := range addressCount { + keyBytes := make([]byte, btcec.PrivKeyBytesLen) + binary.BigEndian.PutUint32( + keyBytes[btcec.PrivKeyBytesLen-4:], uint32(i+1), + ) + _, pubKey := btcec.PrivKeyFromBytes(keyBytes) + addr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(pubKey), + &chaincfg.RegressionNetParams, + ) + require.NoError(b, err) + + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(b, err) + params = append( + params, &Parameters{PkScript: pkScript}, + ) + properties = append( + properties, &walletrpc.AddressProperty{ + Address: addr.EncodeAddress(), + }, + ) + } + + rawClient := &listAddressesClient{ + response: &walletrpc.ListAddressesResponse{ + AccountWithAddresses: []*walletrpc.AccountWithAddresses{{ + Name: waddrmgr.ImportedAddrAccountName, + Addresses: properties, + }}, + }, + } + wallet := &addressListWallet{rawClient: rawClient} + manager, err := NewManager(&ManagerConfig{ + Store: &addressListStore{ + addresses: params, + }, + WalletKit: wallet, + ChainParams: &chaincfg.RegressionNetParams, + }, 1) + require.NoError(b, err) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := manager.loadActiveAddresses(b.Context()); err != nil { + b.Fatal(err) + } + } + b.StopTimer() + require.Zero(b, wallet.imports) + }) + } +} + +func TestLoadActiveAddressesImportsOnlyMissing(t *testing.T) { + _, clientPubKey := test.CreateKey(5000) + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(defaultExpiry), clientPubKey, + defaultServerPubkey, + ) + require.NoError(t, err) + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + rawClient := &listAddressesClient{ + response: &walletrpc.ListAddressesResponse{}, + } + wallet := &addressListWallet{rawClient: rawClient} + params := &Parameters{ + ClientPubkey: clientPubKey, + ServerPubkey: defaultServerPubkey, + PkScript: pkScript, + Expiry: defaultExpiry, + } + manager, err := NewManager(&ManagerConfig{ + Store: &addressListStore{ + addresses: []*Parameters{params}, + }, + WalletKit: wallet, + ChainParams: &chaincfg.RegressionNetParams, + }, 1) + require.NoError(t, err) + + require.NoError(t, manager.loadActiveAddresses(t.Context())) + require.Equal(t, 1, rawClient.calls) + require.Equal(t, 1, wallet.imports) + require.Same(t, params, manager.GetParameters(pkScript)) +} + +func TestMultiAddressRestartRecovery(t *testing.T) { + testContext := NewAddressManagerTestContext(t) + + _, _, err := testContext.manager.NewAddress(t.Context()) + require.NoError(t, err) + changeParams, err := testContext.manager.NewChangeAddress(t.Context()) + require.NoError(t, err) + + addresses, err := testContext.manager.GetAllAddresses(t.Context()) + require.NoError(t, err) + require.Len(t, addresses, 3) + require.EqualValues( + t, swap.StaticAddressKeyFamily, + addresses[0].KeyLocator.Family, + ) + require.EqualValues( + t, swap.StaticMultiAddressKeyFamily, + addresses[1].KeyLocator.Family, + ) + require.EqualValues( + t, swap.StaticAddressChangeKeyFamily, + changeParams.KeyLocator.Family, + ) + + rpcCtx, _, rawWallet := + testContext.manager.cfg.WalletKit.RawClientWithMacAuth(t.Context()) + walletBeforeRestart, err := rawWallet.ListAddresses( + rpcCtx, &walletrpc.ListAddressesRequest{ + AccountName: waddrmgr.ImportedAddrAccountName, + }, + ) + require.NoError(t, err) + require.Len( + t, walletBeforeRestart.GetAccountWithAddresses()[0].GetAddresses(), + 3, + ) + + restarted, err := NewManager( + testContext.manager.cfg, testContext.manager.currentHeight.Load(), + ) + require.NoError(t, err) + require.NoError(t, restarted.loadActiveAddresses(t.Context())) + + walletAfterRestart, err := rawWallet.ListAddresses( + rpcCtx, &walletrpc.ListAddressesRequest{ + AccountName: waddrmgr.ImportedAddrAccountName, + }, + ) + require.NoError(t, err) + require.Len( + t, walletAfterRestart.GetAccountWithAddresses()[0].GetAddresses(), + 3, + ) + + for _, params := range addresses { + recovered := restarted.GetParameters(params.PkScript) + require.NotNil(t, recovered) + require.Equal(t, params.ID, recovered.ID) + require.Equal(t, params.KeyLocator, recovered.KeyLocator) + } +} + +func TestImportAddressTapscriptDuplicateMatching(t *testing.T) { + t.Parallel() + + _, clientPubKey := test.CreateKey(20) + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(defaultExpiry), clientPubKey, + defaultServerPubkey, + ) + require.NoError(t, err) + + duplicateErr := fmt.Sprintf( + "rpc error: address for script hash/key %x already exists", + schnorr.SerializePubKey(staticAddress.TaprootKey), + ) + tests := []struct { + name string + result error + wantErr bool + }{ + { + name: "matching duplicate", + result: errors.New(duplicateErr), + }, + { + name: "unrelated already exists", + result: errors.New("wallet database already exists"), + wantErr: true, + }, + { + name: "different output key", + result: errors.New("address for script hash/key " + + "000000000000000000000000000000000000000000000000" + + "0000000000000000 already exists"), + wantErr: true, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + manager := &Manager{cfg: &ManagerConfig{ + WalletKit: &blockingImportWallet{ + result: testCase.result, + }, + }} + err := manager.importAddressTapscript( + t.Context(), staticAddress, + ) + if testCase.wantErr { + require.ErrorIs(t, err, testCase.result) + } else { + require.NoError(t, err) + } + }) + } } // TestNewAddressValidatesServerResponse tests that the untrusted @@ -233,12 +664,12 @@ func TestNewAddressAcceptsMaxCSVExpiry(t *testing.T) { func GenerateExpectedTaprootAddress(t *ManagerTestContext) ( *btcutil.AddressTaproot, error) { - keyIndex := int32(0) + keyIndex := int32(1) _, pubKey := test.CreateKey(keyIndex) keyDescriptor := &keychain.KeyDescriptor{ KeyLocator: keychain.KeyLocator{ - Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Family: keychain.KeyFamily(swap.StaticMultiAddressKeyFamily), Index: uint32(keyIndex), }, PubKey: pubKey, diff --git a/staticaddr/address/sql_store.go b/staticaddr/address/sql_store.go index 16f113c44..35298867f 100644 --- a/staticaddr/address/sql_store.go +++ b/staticaddr/address/sql_store.go @@ -6,7 +6,6 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/loopdb/sqlc" - "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightningnetwork/lnd/keychain" ) @@ -26,7 +25,7 @@ func NewSqlStore(db *loopdb.BaseDB) *SqlStore { // CreateStaticAddress creates a static address record in the database. func (s *SqlStore) CreateStaticAddress(ctx context.Context, - addrParams *script.Parameters) error { + addrParams *Parameters) error { createArgs := sqlc.CreateStaticAddressParams{ ClientPubkey: addrParams.ClientPubkey.SerializeCompressed(), @@ -51,14 +50,14 @@ func (s *SqlStore) GetStaticAddressID(ctx context.Context, // GetAllStaticAddresses returns all addresses known to the client. func (s *SqlStore) GetAllStaticAddresses(ctx context.Context) ( - []*script.Parameters, error) { + []*Parameters, error) { staticAddresses, err := s.baseDB.Queries.AllStaticAddresses(ctx) if err != nil { return nil, err } - var result []*script.Parameters + var result []*Parameters for _, address := range staticAddresses { res, err := s.toAddressParameters(address) if err != nil { @@ -72,8 +71,8 @@ func (s *SqlStore) GetAllStaticAddresses(ctx context.Context) ( } // GetLegacyParameters returns the first static address created for this L402. -func (s *SqlStore) GetLegacyParameters(ctx context.Context) ( - *script.Parameters, error) { +func (s *SqlStore) GetLegacyParameters(ctx context.Context) (*Parameters, + error) { staticAddress, err := s.baseDB.Queries.GetLegacyAddress(ctx) if err != nil { @@ -86,7 +85,7 @@ func (s *SqlStore) GetLegacyParameters(ctx context.Context) ( // toAddressParameters transforms a database representation of a static address // to an AddressParameters struct. func (s *SqlStore) toAddressParameters(row sqlc.StaticAddress) ( - *script.Parameters, error) { + *Parameters, error) { clientPubkey, err := btcec.ParsePubKey(row.ClientPubkey) if err != nil { @@ -98,7 +97,7 @@ func (s *SqlStore) toAddressParameters(row sqlc.StaticAddress) ( return nil, err } - return &script.Parameters{ + return &Parameters{ ID: row.ID, ClientPubkey: clientPubkey, ServerPubkey: serverPubkey, diff --git a/test/walletkit_mock.go b/test/walletkit_mock.go index ee42fa162..e35d5fbaf 100644 --- a/test/walletkit_mock.go +++ b/test/walletkit_mock.go @@ -5,10 +5,12 @@ import ( "context" "errors" "fmt" + "sort" "sync" "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg" @@ -21,6 +23,7 @@ import ( "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "google.golang.org/grpc" ) // DefaultMockFee is the default value we use for fee estimates when no values @@ -36,6 +39,8 @@ type mockWalletKit struct { feeEstimateLock sync.Mutex feeEstimates map[int32]chainfee.SatPerKWeight minRelayFee chainfee.SatPerKWeight + walletStateLock sync.Mutex + importedTaproot map[string]struct{} // listUnspent holds test UTXOs to be returned by ListUnspent. listUnspent []*lnwallet.Utxo @@ -43,11 +48,55 @@ type mockWalletKit struct { var _ lndclient.WalletKitClient = (*mockWalletKit)(nil) +type mockWalletKitRawClient struct { + walletrpc.WalletKitClient + + wallet *mockWalletKit +} + +func (c *mockWalletKitRawClient) ListAddresses(_ context.Context, + req *walletrpc.ListAddressesRequest, _ ...grpc.CallOption) ( + *walletrpc.ListAddressesResponse, error) { + + if req.GetAccountName() != "" && + req.GetAccountName() != waddrmgr.ImportedAddrAccountName { + + return &walletrpc.ListAddressesResponse{}, nil + } + + c.wallet.walletStateLock.Lock() + addresses := make([]string, 0, len(c.wallet.importedTaproot)) + for addr := range c.wallet.importedTaproot { + addresses = append(addresses, addr) + } + c.wallet.walletStateLock.Unlock() + sort.Strings(addresses) + + if len(addresses) == 0 { + return &walletrpc.ListAddressesResponse{}, nil + } + + properties := make([]*walletrpc.AddressProperty, 0, len(addresses)) + for _, addr := range addresses { + properties = append(properties, &walletrpc.AddressProperty{ + Address: addr, + }) + } + + return &walletrpc.ListAddressesResponse{ + AccountWithAddresses: []*walletrpc.AccountWithAddresses{{ + Name: waddrmgr.ImportedAddrAccountName, + AddressType: walletrpc.AddressType_TAPROOT_PUBKEY, + Addresses: properties, + }}, + }, nil +} + func (m *mockWalletKit) RawClientWithMacAuth( ctx context.Context) (context.Context, time.Duration, walletrpc.WalletKitClient) { - return ctx, 0, nil + return ctx, 0, &mockWalletKitRawClient{wallet: m} } func (m *mockWalletKit) ListUnspent(ctx context.Context, minConfs, @@ -338,5 +387,24 @@ func (m *mockWalletKit) ImportPublicKey(ctx context.Context, func (m *mockWalletKit) ImportTaprootScript(ctx context.Context, tapscript *waddrmgr.Tapscript) (btcutil.Address, error) { - return nil, nil + taprootKey, err := tapscript.TaprootKey() + if err != nil { + return nil, err + } + + addr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(taprootKey), m.lnd.ChainParams, + ) + if err != nil { + return nil, err + } + + m.walletStateLock.Lock() + if m.importedTaproot == nil { + m.importedTaproot = make(map[string]struct{}) + } + m.importedTaproot[addr.EncodeAddress()] = struct{}{} + m.walletStateLock.Unlock() + + return addr, nil } From d3e80a0598a4dd3f2ce8b36f079d2d9db89a608e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 14:23:06 +0200 Subject: [PATCH 04/30] staticaddr/deposit: bind deposits to owning addresses Look up each newly discovered wallet UTXO by script and persist the matching active-address parameters on the deposit. Reject unknown scripts before allocating the timeout sweep address. Use the per-deposit parameters when constructing the FSM, sign descriptor, and unilateral expiry sweep so derived-address recovery uses its owning script and key. --- staticaddr/deposit/actions.go | 15 +- staticaddr/deposit/fsm.go | 18 +-- staticaddr/deposit/fsm_test.go | 28 ++++ staticaddr/deposit/interface.go | 4 + staticaddr/deposit/manager.go | 25 ++-- staticaddr/deposit/manager_reconcile_test.go | 144 ++++++++++++++----- staticaddr/deposit/manager_test.go | 61 ++++---- 7 files changed, 198 insertions(+), 97 deletions(-) diff --git a/staticaddr/deposit/actions.go b/staticaddr/deposit/actions.go index 77560eb24..480848fd7 100644 --- a/staticaddr/deposit/actions.go +++ b/staticaddr/deposit/actions.go @@ -27,9 +27,15 @@ func (f *FSM) PublishDepositExpirySweepAction(ctx context.Context, msgTx := wire.NewMsgTx(2) - params, err := f.cfg.AddressManager.GetStaticAddressParameters(ctx) + if f.deposit.AddressParams == nil { + return f.HandleError(fmt.Errorf("missing static address " + + "parameters")) + } + params := f.deposit.AddressParams + + address, err := f.deposit.GetStaticAddressScript() if err != nil { - return fsm.OnError + return f.HandleError(err) } // Add the deposit outpoint as input to the transaction. @@ -96,11 +102,6 @@ func (f *FSM) PublishDepositExpirySweepAction(ctx context.Context, return f.HandleError(err) } - address, err := f.cfg.AddressManager.GetStaticAddress(ctx) - if err != nil { - return f.HandleError(err) - } - sig := rawSigs[0] msgTx.TxIn[0].Witness, err = address.GenTimeoutWitness(sig) if err != nil { diff --git a/staticaddr/deposit/fsm.go b/staticaddr/deposit/fsm.go index c5bb85c30..723aaa6aa 100644 --- a/staticaddr/deposit/fsm.go +++ b/staticaddr/deposit/fsm.go @@ -181,13 +181,13 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig, finalizedDepositChan chan wire.OutPoint, recoverStateMachine bool) (*FSM, error) { - params, err := cfg.AddressManager.GetStaticAddressParameters(ctx) - if err != nil { - return nil, fmt.Errorf("unable to get static address "+ - "parameters: %w", err) + if deposit.AddressParams == nil { + return nil, fmt.Errorf("missing deposit static address " + + "parameters") } + params := deposit.AddressParams - address, err := cfg.AddressManager.GetStaticAddress(ctx) + address, err := deposit.GetStaticAddressScript() if err != nil { return nil, fmt.Errorf("unable to get static address: %w", err) } @@ -535,10 +535,10 @@ func (f *FSM) Errorf(format string, args ...any) { } // SignDescriptor returns the sign descriptor for the static address output. -func (f *FSM) SignDescriptor(ctx context.Context) (*lndclient.SignDescriptor, +func (f *FSM) SignDescriptor(_ context.Context) (*lndclient.SignDescriptor, error) { - address, err := f.cfg.AddressManager.GetStaticAddress(ctx) + address, err := f.deposit.GetStaticAddressScript() if err != nil { return nil, err } @@ -546,10 +546,10 @@ func (f *FSM) SignDescriptor(ctx context.Context) (*lndclient.SignDescriptor, return &lndclient.SignDescriptor{ WitnessScript: address.TimeoutLeaf.Script, KeyDesc: keychain.KeyDescriptor{ - PubKey: f.params.ClientPubkey, + PubKey: f.deposit.AddressParams.ClientPubkey, }, Output: wire.NewTxOut( - int64(f.deposit.Value), f.params.PkScript, + int64(f.deposit.Value), f.deposit.AddressParams.PkScript, ), HashType: txscript.SigHashDefault, InputIndex: 0, diff --git a/staticaddr/deposit/fsm_test.go b/staticaddr/deposit/fsm_test.go index f174ad1db..a98ae2a8f 100644 --- a/staticaddr/deposit/fsm_test.go +++ b/staticaddr/deposit/fsm_test.go @@ -12,6 +12,34 @@ import ( "github.com/stretchr/testify/require" ) +// TestSignDescriptorUsesDepositAddress verifies unilateral signing uses the +// parameters of the address that owns the deposit, without consulting the +// legacy root address. +func TestSignDescriptorUsesDepositAddress(t *testing.T) { + params := &script.Parameters{ + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: 144, + PkScript: []byte{0x51, 0x20, 0x01}, + } + deposit := &Deposit{ + Value: 100_000, + AddressParams: params, + } + depositFSM := &FSM{deposit: deposit} + + signDesc, err := depositFSM.SignDescriptor(t.Context()) + require.NoError(t, err) + + staticAddress, err := deposit.GetStaticAddressScript() + require.NoError(t, err) + require.Equal(t, staticAddress.TimeoutLeaf.Script, + signDesc.WitnessScript) + require.True(t, params.ClientPubkey.IsEqual(signDesc.KeyDesc.PubKey)) + require.EqualValues(t, deposit.Value, signDesc.Output.Value) + require.Equal(t, params.PkScript, signDesc.Output.PkScript) +} + // TestHandleBlockNotificationIgnoresFinalStates verifies that a block-driven // expiry notification cannot mutate deposits that already reached a final // state but have not yet been removed from the manager's active set. diff --git a/staticaddr/deposit/interface.go b/staticaddr/deposit/interface.go index 8606c7e60..0400cb206 100644 --- a/staticaddr/deposit/interface.go +++ b/staticaddr/deposit/interface.go @@ -39,6 +39,10 @@ type AddressManager interface { GetStaticAddressParameters(ctx context.Context) (*script.Parameters, error) + // GetParameters returns active static address parameters for the given + // pkScript. + GetParameters(pkScript []byte) *script.Parameters + // GetStaticAddress returns the deposit address for the given // client and server public keys. GetStaticAddress(ctx context.Context) (*script.StaticAddress, error) diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 43b166bb4..a67858453 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -358,6 +358,16 @@ func (m *Manager) createNewDeposit(ctx context.Context, return nil, err } + addressParams := m.cfg.AddressManager.GetParameters(utxo.PkScript) + if addressParams == nil { + return nil, fmt.Errorf("missing static address parameters "+ + "for deposit %v", utxo.OutPoint) + } + if addressParams.ID <= 0 { + return nil, fmt.Errorf("missing static address ID for deposit %v", + utxo.OutPoint) + } + // Get the sweep pk script. addr, err := m.cfg.WalletKit.NextAddr( ctx, lnwallet.DefaultAccountName, @@ -377,21 +387,6 @@ func (m *Manager) createNewDeposit(ctx context.Context, return nil, err } - addressParams, err := m.cfg.AddressManager. - GetStaticAddressParameters(ctx) - if err != nil { - return nil, fmt.Errorf("unable to get static address parameters: %w", - err) - } - if addressParams == nil { - return nil, fmt.Errorf("missing static address parameters for deposit %v", - utxo.OutPoint) - } - if addressParams.ID <= 0 { - return nil, fmt.Errorf("missing static address ID for deposit %v", - utxo.OutPoint) - } - deposit := &Deposit{ ID: id, state: Deposited, diff --git a/staticaddr/deposit/manager_reconcile_test.go b/staticaddr/deposit/manager_reconcile_test.go index b9e14bbaa..f39c5ca04 100644 --- a/staticaddr/deposit/manager_reconcile_test.go +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -2,7 +2,6 @@ package deposit import ( "context" - "errors" "strings" "sync" "sync/atomic" @@ -16,6 +15,7 @@ import ( "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnwallet" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -41,7 +41,7 @@ func TestReconcileDepositsSerialized(t *testing.T) { "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), ).Return([]*lnwallet.Utxo{utxo}, nil) mockAddressManager.On( - "GetStaticAddressParameters", mock.Anything, + "GetParameters", utxo.PkScript, ).Return(&script.Parameters{ ID: 1, ClientPubkey: defaultServerPubkey, @@ -49,10 +49,7 @@ func TestReconcileDepositsSerialized(t *testing.T) { Expiry: defaultExpiry, PkScript: utxo.PkScript, ProtocolVersion: 999, - }, nil) - mockAddressManager.On( - "GetStaticAddress", mock.Anything, - ).Return((*script.StaticAddress)(nil), errors.New("fsm init failed")) + }) mockStore := new(mockStore) var createCalls atomic.Int32 @@ -147,7 +144,7 @@ func TestReconcileConfirmedDepositUsesCurrentHeight(t *testing.T) { "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), ).Return([]*lnwallet.Utxo{utxo}, nil) mockAddressManager.On( - "GetStaticAddressParameters", mock.Anything, + "GetParameters", utxo.PkScript, ).Return(&script.Parameters{ ID: 1, ClientPubkey: defaultServerPubkey, @@ -155,10 +152,7 @@ func TestReconcileConfirmedDepositUsesCurrentHeight(t *testing.T) { Expiry: defaultExpiry, PkScript: utxo.PkScript, ProtocolVersion: 999, - }, nil) - mockAddressManager.On( - "GetStaticAddress", mock.Anything, - ).Return((*script.StaticAddress)(nil), errors.New("fsm init failed")) + }) mockStore := new(mockStore) mockStore.On( @@ -182,6 +176,99 @@ func TestReconcileConfirmedDepositUsesCurrentHeight(t *testing.T) { require.ErrorContains(t, err, "unable to start new deposit FSM") } +// TestCreateNewDepositBindsOwningAddress verifies that each wallet UTXO is +// persisted with the parameters of the static address matching its script. +func TestCreateNewDepositBindsOwningAddress(t *testing.T) { + ctx := t.Context() + mockLnd := test.NewMockLnd() + + firstUtxo := &lnwallet.Utxo{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{31}, + Index: 1, + }, + Value: btcutil.Amount(100_000), + PkScript: []byte{0x51, 0x01}, + } + secondUtxo := &lnwallet.Utxo{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{32}, + Index: 2, + }, + Value: btcutil.Amount(200_000), + PkScript: []byte{0x51, 0x02}, + } + + firstParams := &script.Parameters{ + ID: 11, + PkScript: firstUtxo.PkScript, + KeyLocator: keychain.KeyLocator{Index: 11}, + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + } + secondParams := &script.Parameters{ + ID: 22, + PkScript: secondUtxo.PkScript, + KeyLocator: keychain.KeyLocator{Index: 22}, + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + } + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "GetParameters", firstUtxo.PkScript, + ).Return(firstParams).Once() + mockAddressManager.On( + "GetParameters", secondUtxo.PkScript, + ).Return(secondParams).Once() + + created := make(map[wire.OutPoint]*Deposit) + mockStore := new(mockStore) + mockStore.On( + "CreateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(args mock.Arguments) { + deposit := args.Get(1).(*Deposit) + created[deposit.OutPoint] = deposit + }).Times(2) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: mockStore, + WalletKit: mockLnd.WalletKit, + }) + + firstDeposit, err := manager.createNewDeposit(ctx, firstUtxo, 0) + require.NoError(t, err) + secondDeposit, err := manager.createNewDeposit(ctx, secondUtxo, 0) + require.NoError(t, err) + + require.Same(t, firstParams, firstDeposit.AddressParams) + require.Same(t, secondParams, secondDeposit.AddressParams) + require.Same(t, firstDeposit, created[firstUtxo.OutPoint]) + require.Same(t, secondDeposit, created[secondUtxo.OutPoint]) + require.EqualValues(t, 11, firstDeposit.AddressParams.KeyLocator.Index) + require.EqualValues(t, 22, secondDeposit.AddressParams.KeyLocator.Index) + + unknownUtxo := &lnwallet.Utxo{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{33}, + Index: 3, + }, + PkScript: []byte{0x51, 0x03}, + } + mockAddressManager.On( + "GetParameters", unknownUtxo.PkScript, + ).Return((*script.Parameters)(nil)).Once() + + _, err = manager.createNewDeposit(ctx, unknownUtxo, 0) + require.ErrorContains(t, err, "missing static address parameters") + _, ok := manager.deposits[unknownUtxo.OutPoint] + require.False(t, ok) + + mockAddressManager.AssertExpectations(t) + mockStore.AssertExpectations(t) +} + // TestUpdateDepositConfirmationsResetsReorgedDeposit verifies that a deposit // which remains wallet-visible but loses confirmations has its confirmation // height reset. This can happen if a confirmed transaction is reorged back into @@ -493,6 +580,12 @@ func TestReconcileDepositsReactivatesReappearedDeposit(t *testing.T) { OutPoint: outpoint, Value: btcutil.Amount(100_000), ConfirmationHeight: 77, + AddressParams: &script.Parameters{ + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + ProtocolVersion: version.ProtocolVersion_V0, + }, } deposit.SetState(Deposited) @@ -506,16 +599,6 @@ func TestReconcileDepositsReactivatesReappearedDeposit(t *testing.T) { mockAddressManager.On( "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), ).Return([]*lnwallet.Utxo{utxo}, nil) - mockAddressManager.On( - "GetStaticAddressParameters", mock.Anything, - ).Return(&script.Parameters{ - ID: 1, - ProtocolVersion: version.ProtocolVersion_V0, - }, nil) - mockAddressManager.On( - "GetStaticAddress", mock.Anything, - ).Return((*script.StaticAddress)(nil), nil) - mockStore := new(mockStore) var updateStates []fsm.StateType mockStore.On( @@ -569,10 +652,6 @@ func TestReconcileDepositsKeepsInactiveOnFSMStartFailure(t *testing.T) { mockAddressManager.On( "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), ).Return([]*lnwallet.Utxo{utxo}, nil) - mockAddressManager.On( - "GetStaticAddressParameters", mock.Anything, - ).Return((*script.Parameters)(nil), errors.New("fsm init failed")) - var ( updateStates []fsm.StateType updateHeights []int64 @@ -639,10 +718,6 @@ func TestReconcileDepositsDeactivatesBeforeActivationFailure(t *testing.T) { mockAddressManager.On( "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), ).Return([]*lnwallet.Utxo{utxo}, nil) - mockAddressManager.On( - "GetStaticAddressParameters", mock.Anything, - ).Return((*script.Parameters)(nil), errors.New("fsm init failed")) - manager := NewManager(&ManagerConfig{ AddressManager: mockAddressManager, Store: new(mockStore), @@ -709,14 +784,15 @@ func TestReconcileReplacementDepositCreatesNewDeposit(t *testing.T) { "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), ).Return([]*lnwallet.Utxo{utxo}, nil) mockAddressManager.On( - "GetStaticAddressParameters", mock.Anything, + "GetParameters", utxo.PkScript, ).Return(&script.Parameters{ ID: 1, + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: utxo.PkScript, ProtocolVersion: version.ProtocolVersion_V0, - }, nil) - mockAddressManager.On( - "GetStaticAddress", mock.Anything, - ).Return((*script.StaticAddress)(nil), nil) + }) mockStore := new(mockStore) var createdDeposit *Deposit diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 78663f9a7..6b8c086cb 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -13,11 +13,11 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "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/input" "github.com/lightningnetwork/lnd/lnrpc/chainrpc" "github.com/lightningnetwork/lnd/lnwallet" "github.com/stretchr/testify/mock" @@ -121,6 +121,17 @@ func (m *mockAddressManager) GetStaticAddressParameters(ctx context.Context) ( args.Error(1) } +func (m *mockAddressManager) GetParameters( + pkScript []byte) *script.Parameters { + + args := m.Called(pkScript) + if args.Get(0) == nil { + return nil + } + + return args.Get(0).(*script.Parameters) +} + func (m *mockAddressManager) GetStaticAddress(ctx context.Context) ( *script.StaticAddress, error) { @@ -510,6 +521,13 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { blockErrChan := make(chan error) ID, err := GetRandomDepositID() + require.NoError(t, err) + + keyDescriptor, err := mockLnd.WalletKit.DeriveNextKey( + context.Background(), swap.StaticAddressKeyFamily, + ) + require.NoError(t, err) + utxo := &lnwallet.Utxo{ AddressType: lnwallet.TaprootPubkey, Value: btcutil.Amount(100000), @@ -520,7 +538,15 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { Index: 0xffffffff, }, } - require.NoError(t, err) + addrParams := &script.Parameters{ + ID: 1, + ClientPubkey: keyDescriptor.PubKey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: utxo.PkScript, + KeyLocator: keyDescriptor.KeyLocator, + ProtocolVersion: version.ProtocolVersion_V0, + } storedDeposits := []*Deposit{ { ID: ID, @@ -529,6 +555,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { Value: utxo.Value, ConfirmationHeight: 3, TimeOutSweepPkScript: []byte{0x42, 0x21, 0x69}, + AddressParams: addrParams, }, } @@ -541,12 +568,6 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { ).Return(nil) var manager *Manager - mockAddressManager.On( - "GetStaticAddressParameters", mock.Anything, - ).Return(&script.Parameters{ - Expiry: defaultExpiry, - }, nil) - mockAddressManager.On( "ListUnspent", mock.Anything, mock.Anything, mock.Anything, ).Return(func() []*lnwallet.Utxo { @@ -595,29 +616,5 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { blockErrChan: blockErrChan, } - staticAddress := generateStaticAddress( - context.Background(), testContext, - ) - mockAddressManager.On( - "GetStaticAddress", mock.Anything, - ).Return(staticAddress, nil) - return testContext } - -func generateStaticAddress(ctx context.Context, - t *ManagerTestContext) *script.StaticAddress { - - keyDescriptor, err := t.mockLnd.WalletKit.DeriveNextKey( - ctx, swap.StaticAddressKeyFamily, - ) - require.NoError(t.context.T, err) - - staticAddress, err := script.NewStaticAddress( - input.MuSig2Version100RC2, int64(defaultExpiry), - keyDescriptor.PubKey, defaultServerPubkey, - ) - require.NoError(t.context.T, err) - - return staticAddress -} From e9f7720c454a9eae2e104fbdd211b6367fbeddb9 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 27 Aug 2026 10:44:21 +0200 Subject: [PATCH 05/30] staticaddr/deposit: detect replaced expiry sweeps Register timeout-sweep confirmations by destination script instead of the originally published txid. This lets recovery detect an RBF replacement after restart with a stale txid. --- staticaddr/deposit/actions.go | 11 ++----- staticaddr/deposit/actions_test.go | 46 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/staticaddr/deposit/actions.go b/staticaddr/deposit/actions.go index 480848fd7..03a65e00b 100644 --- a/staticaddr/deposit/actions.go +++ b/staticaddr/deposit/actions.go @@ -6,7 +6,6 @@ import ( "fmt" "strings" - "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/fsm" @@ -132,14 +131,10 @@ func (f *FSM) PublishDepositExpirySweepAction(ctx context.Context, func (f *FSM) WaitForExpirySweepAction(ctx context.Context, _ fsm.EventContext) fsm.EventType { - var txID *chainhash.Hash - // Only pass the txid if we know it from our own publication. - if f.deposit.ExpirySweepTxid != (chainhash.Hash{}) { - txID = &f.deposit.ExpirySweepTxid - } - + // Register by script only so an RBF replacement of the timeout sweep is + // still detected after restart with a stale ExpirySweepTxid. spendChan, errSpendChan, err := f.cfg.ChainNotifier.RegisterConfirmationsNtfn( //nolint:lll - ctx, txID, f.deposit.TimeOutSweepPkScript, DefaultConfTarget, + ctx, nil, f.deposit.TimeOutSweepPkScript, DefaultConfTarget, int32(f.deposit.GetConfirmationHeight()), ) if err != nil { diff --git a/staticaddr/deposit/actions_test.go b/staticaddr/deposit/actions_test.go index 8c0211211..15a913999 100644 --- a/staticaddr/deposit/actions_test.go +++ b/staticaddr/deposit/actions_test.go @@ -8,6 +8,8 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -52,6 +54,50 @@ func TestFinalizeDepositActionDoesNotBlock(t *testing.T) { } } +func TestWaitForExpirySweepActionRegistersByScriptOnly(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + timeoutPkScript := []byte{0x51, 0x20, 0x01} + confChan := make(chan *chainntnfs.TxConfirmation, 1) + errChan := make(chan error, 1) + + chainNotifier := &MockChainNotifier{} + chainNotifier.On( + "RegisterConfirmationsNtfn", + mock.Anything, + mock.MatchedBy(func(txid *chainhash.Hash) bool { + return txid == nil + }), + timeoutPkScript, + int32(DefaultConfTarget), + int32(42), + ).Return(confChan, errChan, nil).Once() + + depositFSM := &FSM{ + cfg: &ManagerConfig{ + ChainNotifier: chainNotifier, + }, + deposit: &Deposit{ + ConfirmationHeight: 42, + ExpirySweepTxid: chainhash.Hash{9}, + TimeOutSweepPkScript: timeoutPkScript, + }, + } + + confirmedTx := wire.NewMsgTx(2) + confirmedTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: timeoutPkScript, + }) + confChan <- &chainntnfs.TxConfirmation{Tx: confirmedTx} + + event := depositFSM.WaitForExpirySweepAction(ctx, nil) + require.Equal(t, OnExpirySwept, event) + require.Equal(t, confirmedTx.TxHash(), depositFSM.deposit.ExpirySweepTxid) + chainNotifier.AssertExpectations(t) +} + // TestFinalizeDepositActionIgnoresRequestCancellation ensures the cleanup // notification is tied to the FSM lifetime, not the caller's request context. func TestFinalizeDepositActionIgnoresRequestCancellation(t *testing.T) { From 9ebf8b9304f043a55a18321dd6230c34fedb5d12 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 27 Aug 2026 10:44:58 +0200 Subject: [PATCH 06/30] staticaddr: create signing sessions from deposit keys Construct each cooperative MuSig2 session from the address parameters stored on its deposit. This prepares loop-ins and withdrawals to sign inputs belonging to different derived static addresses. Clean up sessions created before a later setup failure. Reject duplicate deposit outpoints to avoid leaking signer state. Validate transaction inputs, session handles, and nonce counts before signing so malformed responses fail before any signer operation. --- staticaddr/loopin/actions.go | 9 +- staticaddr/loopin/loopin.go | 32 +++- staticaddr/loopin/manager.go | 12 +- staticaddr/loopin/sign_musig_test.go | 124 +++++++++++++ staticaddr/staticutil/utils.go | 104 ++++++++--- staticaddr/staticutil/utils_test.go | 261 +++++++++++++++++++++++---- staticaddr/withdraw/manager.go | 33 ++-- 7 files changed, 483 insertions(+), 92 deletions(-) create mode 100644 staticaddr/loopin/sign_musig_test.go diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 3dc1c029f..78403d212 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -601,8 +601,7 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, // rates. createSession := staticutil.CreateMusig2Sessions htlcSessions, clientHtlcNonces, err := createSession( - ctx, f.cfg.Signer, f.loopIn.Deposits, f.loopIn.AddressParams, - f.loopIn.Address, + ctx, f.cfg.Signer, f.loopIn.Deposits, ) if err != nil { err = fmt.Errorf("unable to create musig2 sessions: %w", err) @@ -612,8 +611,7 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, defer f.cleanUpSessions(ctx, htlcSessions) htlcSessionsHighFee, highFeeNonces, err := createSession( - ctx, f.cfg.Signer, f.loopIn.Deposits, f.loopIn.AddressParams, - f.loopIn.Address, + ctx, f.cfg.Signer, f.loopIn.Deposits, ) if err != nil { return f.HandleError(err) @@ -621,8 +619,7 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, defer f.cleanUpSessions(ctx, htlcSessionsHighFee) htlcSessionsExtremelyHighFee, extremelyHighNonces, err := createSession( - ctx, f.cfg.Signer, f.loopIn.Deposits, f.loopIn.AddressParams, - f.loopIn.Address, + ctx, f.cfg.Signer, f.loopIn.Deposits, ) if err != nil { err = fmt.Errorf("unable to convert nonces: %w", err) diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index 7fcc3ff9a..208fa4827 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "reflect" "sync" "time" @@ -204,26 +203,43 @@ func (l *StaticAddressLoopIn) signMusig2Tx(ctx context.Context, musig2sessions []*input.MuSig2SessionInfo, counterPartyNonces [][musig2.PubNonceSize]byte) ([][]byte, error) { - prevOuts, err := staticutil.ToPrevOuts( - l.Deposits, l.AddressParams.PkScript, - ) + prevOuts, err := staticutil.ToPrevOuts(l.Deposits) if err != nil { return nil, err } prevOutFetcher := txscript.NewMultiPrevOutFetcher(prevOuts) outpoints := l.Outpoints() - sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher) - sigs := make([][]byte, len(outpoints)) + if len(tx.TxIn) != len(outpoints) { + return nil, fmt.Errorf("htlc tx input count %d does not "+ + "match deposits %d", len(tx.TxIn), len(outpoints)) + } + if len(musig2sessions) != len(outpoints) { + return nil, fmt.Errorf("musig2 session count %d does not "+ + "match deposits %d", len(musig2sessions), len(outpoints)) + } + if len(counterPartyNonces) != len(outpoints) { + return nil, fmt.Errorf("server nonce count %d does not "+ + "match deposits %d", len(counterPartyNonces), + len(outpoints)) + } for idx, outpoint := range outpoints { - if !reflect.DeepEqual(tx.TxIn[idx].PreviousOutPoint, - outpoint) { + if musig2sessions[idx] == nil { + return nil, fmt.Errorf("missing musig2 session for "+ + "deposit input %d", idx) + } + if tx.TxIn[idx].PreviousOutPoint != outpoint { return nil, fmt.Errorf("tx input does not match " + "deposits") } + } + + sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher) + sigs := make([][]byte, len(outpoints)) + for idx := range outpoints { taprootSigHash, err := txscript.CalcTaprootSignatureHash( sigHashes, txscript.SigHashDefault, tx, idx, prevOutFetcher, diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 47a447fc3..2f1e0e778 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -376,8 +376,18 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, map[string]*swapserverrpc.ClientSweeplessSigningInfo, len(req.DepositToNonces), ) + depositMap := make(map[string]*deposit.Deposit, len(loopIn.Deposits)) + for _, d := range loopIn.Deposits { + depositMap[d.String()] = d + } for depositOutpoint, nonce := range req.DepositToNonces { + d, ok := depositMap[depositOutpoint] + if !ok { + return fmt.Errorf("deposit %v not found in loop-in", + depositOutpoint) + } + taprootSigHash, err := txscript.CalcTaprootSignatureHash( sigHashes, txscript.SigHashDefault, sweepPacket.UnsignedTx, @@ -396,7 +406,7 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, } musig2Session, err := staticutil.CreateMusig2Session( - ctx, m.cfg.Signer, loopIn.AddressParams, loopIn.Address, + ctx, m.cfg.Signer, d, ) if err != nil { return err diff --git a/staticaddr/loopin/sign_musig_test.go b/staticaddr/loopin/sign_musig_test.go new file mode 100644 index 000000000..5a2cc7e36 --- /dev/null +++ b/staticaddr/loopin/sign_musig_test.go @@ -0,0 +1,124 @@ +package loopin + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" +) + +// TestSignMusig2TxRejectsMalformedInputs verifies malformed signing inputs fail +// cleanly before any MuSig2 operation is attempted. +func TestSignMusig2TxRejectsMalformedInputs(t *testing.T) { + 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 := &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PkScript: pkScript, + Expiry: 4032, + ProtocolVersion: version.ProtocolVersion_V0, + } + + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0xdd}, + Index: 0, + }, + Value: 500_000, + AddressParams: addrParams, + } + loopIn := &StaticAddressLoopIn{ + SwapHash: lntypes.Hash{4, 5, 6}, + HtlcCltvExpiry: 800, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + Deposits: []*deposit.Deposit{dep}, + HtlcTxFeeRate: chainfee.SatPerKWeight(253), + } + + validSessions := []*input.MuSig2SessionInfo{{}} + validNonces := make([][musig2.PubNonceSize]byte, 1) + tests := []struct { + name string + mutateTx func(*wire.MsgTx) + sessions []*input.MuSig2SessionInfo + nonces [][musig2.PubNonceSize]byte + errorMatch string + }{ + { + name: "transaction input count", + mutateTx: func(tx *wire.MsgTx) { + tx.TxIn = nil + }, + sessions: validSessions, + nonces: validNonces, + errorMatch: "htlc tx input count", + }, + { + name: "session count", + nonces: validNonces, + errorMatch: "musig2 session count", + }, + { + name: "server nonce count", + sessions: validSessions, + errorMatch: "server nonce count", + }, + { + name: "nil session", + sessions: []*input.MuSig2SessionInfo{nil}, + nonces: validNonces, + errorMatch: "missing musig2 session", + }, + { + name: "transaction input outpoint", + mutateTx: func(tx *wire.MsgTx) { + tx.TxIn[0].PreviousOutPoint.Index++ + }, + sessions: validSessions, + nonces: validNonces, + errorMatch: "tx input does not match deposits", + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + htlcTx, err := loopIn.createHtlcTx( + network, loopIn.HtlcTxFeeRate, 1, + ) + require.NoError(t, err) + if testCase.mutateTx != nil { + testCase.mutateTx(htlcTx) + } + + _, err = loopIn.signMusig2Tx( + t.Context(), htlcTx, &noopSigner{}, + testCase.sessions, testCase.nonces, + ) + require.ErrorContains(t, err, testCase.errorMatch) + }) + } +} diff --git a/staticaddr/staticutil/utils.go b/staticaddr/staticutil/utils.go index a25093339..9d2175747 100644 --- a/staticaddr/staticutil/utils.go +++ b/staticaddr/staticutil/utils.go @@ -3,6 +3,7 @@ package staticutil import ( "bytes" "context" + "errors" "fmt" "sort" @@ -12,7 +13,6 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/staticaddr/deposit" - "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnrpc" @@ -21,8 +21,12 @@ import ( ) // ToPrevOuts converts a slice of deposits to a map of outpoints to TxOuts. -func ToPrevOuts(deposits []*deposit.Deposit, - pkScript []byte) (map[wire.OutPoint]*wire.TxOut, error) { +// +// Each deposit carries the static address parameters that produced its output. +// Using the per-deposit script here keeps signing correct when one transaction +// spends deposits from multiple static addresses. +func ToPrevOuts(deposits []*deposit.Deposit) ( + map[wire.OutPoint]*wire.TxOut, error) { outpoints := make([]wire.OutPoint, len(deposits)) for i, d := range deposits { @@ -35,9 +39,13 @@ func ToPrevOuts(deposits []*deposit.Deposit, prevOuts := make(map[wire.OutPoint]*wire.TxOut, len(deposits)) for i, d := range deposits { outpoint := outpoints[i] + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address "+ + "parameters for deposit %v", d.OutPoint) + } txOut := &wire.TxOut{ Value: int64(d.Value), - PkScript: pkScript, + PkScript: d.AddressParams.PkScript, } prevOuts[outpoint] = txOut } @@ -47,36 +55,42 @@ func ToPrevOuts(deposits []*deposit.Deposit, // CreateMusig2Sessions creates a musig2 session for a number of deposits. func CreateMusig2Sessions(ctx context.Context, - signer lndclient.SignerClient, deposits []*deposit.Deposit, - addrParams *script.Parameters, - staticAddress *script.StaticAddress) ([]*input.MuSig2SessionInfo, + signer lndclient.SignerClient, deposits []*deposit.Deposit) ( + []*input.MuSig2SessionInfo, [][]byte, error) { musig2Sessions := make([]*input.MuSig2SessionInfo, len(deposits)) clientNonces := make([][]byte, len(deposits)) + createdSessions := make(map[string]*input.MuSig2SessionInfo) // Create the sessions and nonces from the deposits. for i := range len(deposits) { session, err := CreateMusig2Session( - ctx, signer, addrParams, staticAddress, + ctx, signer, deposits[i], ) if err != nil { - return nil, nil, err + return nil, nil, errors.Join( + err, CleanupMusig2Sessions( + ctx, signer, createdSessions, + ), + ) } musig2Sessions[i] = session clientNonces[i] = session.PublicNonce[:] + createdSessions[fmt.Sprintf("%d", i)] = session } return musig2Sessions, clientNonces, nil } // CreateMusig2SessionsPerDeposit creates a musig2 session for a number of -// deposits. +// deposits and returns the sessions keyed by outpoint string. +// +// The per-deposit keying mirrors the server response format and avoids relying +// on positional ordering after the request crosses the wire. func CreateMusig2SessionsPerDeposit(ctx context.Context, - signer lndclient.SignerClient, deposits []*deposit.Deposit, - addrParams *script.Parameters, - staticAddress *script.StaticAddress) ( + signer lndclient.SignerClient, deposits []*deposit.Deposit) ( map[string]*input.MuSig2SessionInfo, map[string][]byte, map[string]int, error) { @@ -86,25 +100,73 @@ func CreateMusig2SessionsPerDeposit(ctx context.Context, // Create the musig2 sessions for the sweepless sweep tx. for i, deposit := range deposits { + depositKey := deposit.String() + if _, ok := sessions[depositKey]; ok { + err := fmt.Errorf("duplicate outpoint %v", depositKey) + return nil, nil, nil, errors.Join( + err, CleanupMusig2Sessions(ctx, signer, sessions), + ) + } + session, err := CreateMusig2Session( - ctx, signer, addrParams, staticAddress, + ctx, signer, deposit, ) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, errors.Join( + err, CleanupMusig2Sessions(ctx, signer, sessions), + ) } - sessions[deposit.String()] = session - nonces[deposit.String()] = session.PublicNonce[:] - depositToIdx[deposit.String()] = i + sessions[depositKey] = session + nonces[depositKey] = session.PublicNonce[:] + depositToIdx[depositKey] = i } return sessions, nonces, depositToIdx, nil } -// CreateMusig2Session creates a musig2 session for the deposit. +// CleanupMusig2Sessions releases all supplied MuSig2 sessions. +func CleanupMusig2Sessions(ctx context.Context, + signer lndclient.SignerClient, + sessions map[string]*input.MuSig2SessionInfo) error { + + var cleanupErr error + for depositKey, session := range sessions { + if session == nil { + continue + } + + err := signer.MuSig2Cleanup( + context.WithoutCancel(ctx), session.SessionID, + ) + if err != nil { + cleanupErr = errors.Join( + cleanupErr, fmt.Errorf("unable to clean up MuSig2 "+ + "session for deposit %v: %w", depositKey, err), + ) + } + } + + return cleanupErr +} + +// CreateMusig2Session creates a musig2 session for the deposit's static +// address. func CreateMusig2Session(ctx context.Context, - signer lndclient.SignerClient, addrParams *script.Parameters, - staticAddress *script.StaticAddress) (*input.MuSig2SessionInfo, error) { + signer lndclient.SignerClient, d *deposit.Deposit) ( + *input.MuSig2SessionInfo, error) { + + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address parameters "+ + "for deposit %v", d.OutPoint) + } + + staticAddress, err := d.GetStaticAddressScript() + if err != nil { + return nil, err + } + + addrParams := d.AddressParams signers := [][]byte{ addrParams.ClientPubkey.SerializeCompressed(), diff --git a/staticaddr/staticutil/utils_test.go b/staticaddr/staticutil/utils_test.go index ae68b4895..98ffd3187 100644 --- a/staticaddr/staticutil/utils_test.go +++ b/staticaddr/staticutil/utils_test.go @@ -3,12 +3,14 @@ package staticutil import ( "bytes" "context" + "errors" "testing" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swapserverrpc" @@ -16,11 +18,74 @@ import ( "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/signrpc" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/require" ) +type sessionCreateCall struct { + version input.MuSig2Version + keyLocator keychain.KeyLocator + signers [][]byte + taprootTweak []byte + keySpendOnly bool +} + +type sessionCleanupSigner struct { + lndclient.SignerClient + + createCalls int + failCreateAt int + createArgs []sessionCreateCall + cleaned [][32]byte + cleanupCtxErr []error +} + +func (s *sessionCleanupSigner) MuSig2CreateSession(_ context.Context, + version input.MuSig2Version, keyLocator *keychain.KeyLocator, + signers [][]byte, opts ...lndclient.MuSig2SessionOpts) ( + *input.MuSig2SessionInfo, error) { + + request := &signrpc.MuSig2SessionRequest{} + for _, opt := range opts { + opt(request) + } + + call := sessionCreateCall{ + version: version, + keyLocator: *keyLocator, + signers: make([][]byte, len(signers)), + } + for i := range signers { + call.signers[i] = bytes.Clone(signers[i]) + } + if request.TaprootTweak != nil { + call.taprootTweak = bytes.Clone( + request.TaprootTweak.ScriptRoot, + ) + call.keySpendOnly = request.TaprootTweak.KeySpendOnly + } + s.createArgs = append(s.createArgs, call) + + s.createCalls++ + if s.createCalls == s.failCreateAt { + return nil, errors.New("session creation failed") + } + + sessionID := [32]byte{byte(s.createCalls)} + return &input.MuSig2SessionInfo{SessionID: sessionID}, nil +} + +func (s *sessionCleanupSigner) MuSig2Cleanup(ctx context.Context, + sessionID [32]byte) error { + + s.cleaned = append(s.cleaned, sessionID) + s.cleanupCtxErr = append(s.cleanupCtxErr, ctx.Err()) + + return nil +} + // mustHash converts a hex string to a chainhash.Hash and panics on error. func mustHash(t *testing.T, s string) chainhash.Hash { t.Helper() @@ -36,7 +101,8 @@ func TestToPrevOuts_Success(t *testing.T) { Hash: mustHash(t, "0000000000000000000000000000000000000000000000000000000000000001"), Index: 0, }, - Value: btcutil.Amount(12345), + Value: btcutil.Amount(12345), + AddressParams: &script.Parameters{PkScript: []byte{0x51}}, } d2 := &deposit.Deposit{ @@ -44,12 +110,11 @@ func TestToPrevOuts_Success(t *testing.T) { Hash: mustHash(t, "1111111111111111111111111111111111111111111111111111111111111111"), Index: 7, }, - Value: btcutil.Amount(987654321), + Value: btcutil.Amount(987654321), + AddressParams: &script.Parameters{PkScript: []byte{0x52}}, } - pkScript := []byte{0x51, 0x21, 0x02, 0x52} // arbitrary bytes - - prevOuts, err := ToPrevOuts([]*deposit.Deposit{d1, d2}, pkScript) + prevOuts, err := ToPrevOuts([]*deposit.Deposit{d1, d2}) require.NoError(t, err) // We expect two entries. @@ -59,13 +124,13 @@ func TestToPrevOuts_Success(t *testing.T) { txOut1, ok := prevOuts[d1.OutPoint] require.True(t, ok, "expected outpoint d1 to be present") require.EqualValues(t, int64(d1.Value), txOut1.Value) - require.Equal(t, pkScript, txOut1.PkScript) + require.Equal(t, d1.AddressParams.PkScript, txOut1.PkScript) // Check the second outpoint mapping. txOut2, ok := prevOuts[d2.OutPoint] require.True(t, ok, "expected outpoint d2 to be present") require.EqualValues(t, int64(d2.Value), txOut2.Value) - require.Equal(t, pkScript, txOut2.PkScript) + require.Equal(t, d2.AddressParams.PkScript, txOut2.PkScript) // Ensure the keys in the map are exactly the outpoints we provided. for op := range prevOuts { @@ -80,13 +145,34 @@ func TestToPrevOuts_DuplicateOutpoint(t *testing.T) { Index: 2, } - d1 := &deposit.Deposit{OutPoint: shared, Value: btcutil.Amount(100)} - d2 := &deposit.Deposit{OutPoint: shared, Value: btcutil.Amount(200)} + d1 := &deposit.Deposit{ + OutPoint: shared, + Value: btcutil.Amount(100), + AddressParams: &script.Parameters{PkScript: []byte{0x00}}, + } + d2 := &deposit.Deposit{ + OutPoint: shared, + Value: btcutil.Amount(200), + AddressParams: &script.Parameters{PkScript: []byte{0x01}}, + } - _, err := ToPrevOuts([]*deposit.Deposit{d1, d2}, []byte{0x00}) + _, err := ToPrevOuts([]*deposit.Deposit{d1, d2}) require.Error(t, err) } +func TestToPrevOutsMissingAddressParams(t *testing.T) { + d := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: mustHash(t, "3333333333333333333333333333333333333333333333333333333333333333"), + Index: 3, + }, + Value: btcutil.Amount(100), + } + + _, err := ToPrevOuts([]*deposit.Deposit{d}) + require.ErrorContains(t, err, "missing static address parameters") +} + func TestGetPrevoutInfo_ConversionAndSorting(t *testing.T) { // Helper to create a hash from string. must := func(s string) chainhash.Hash { @@ -182,22 +268,72 @@ func TestCreateMusig2Session_Success(t *testing.T) { KeyLocator: keychain.KeyLocator{Family: 1, Index: 2}, } - // Build a static address for tweak options. - staticAddr, err := script.NewStaticAddress( - input.MuSig2Version100RC2, int64(params.Expiry), params.ClientPubkey, params.ServerPubkey, - ) - require.NoError(t, err) - - sess, err := CreateMusig2Session(context.Background(), signer, params, staticAddr) + d := &deposit.Deposit{AddressParams: params} + sess, err := CreateMusig2Session(context.Background(), signer, d) require.NoError(t, err) require.NotNil(t, sess) } -func TestCreateMusig2Sessions_Multiple(t *testing.T) { - lnd := looptest.NewMockLnd() - signer := lnd.Signer +func TestCreateMusig2SessionsUsesDepositAddressParams(t *testing.T) { + clientKey1, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey1, err := btcec.NewPrivateKey() + require.NoError(t, err) + clientKey2, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey2, err := btcec.NewPrivateKey() + require.NoError(t, err) + + params1 := &script.Parameters{ + ClientPubkey: clientKey1.PubKey(), + ServerPubkey: serverKey1.PubKey(), + Expiry: 12, + PkScript: []byte{0xaa}, + KeyLocator: keychain.KeyLocator{Family: 9, Index: 8}, + } + params2 := &script.Parameters{ + ClientPubkey: clientKey2.PubKey(), + ServerPubkey: serverKey2.PubKey(), + Expiry: 144, + PkScript: []byte{0xbb}, + KeyLocator: keychain.KeyLocator{Family: 19, Index: 18}, + } - // Keys/params/static address. + deposits := []*deposit.Deposit{ + {OutPoint: wire.OutPoint{Index: 0}, AddressParams: params1}, + {OutPoint: wire.OutPoint{Index: 1}, AddressParams: params2}, + } + signer := &sessionCleanupSigner{} + + sessions, nonces, err := CreateMusig2Sessions( + t.Context(), signer, deposits, + ) + require.NoError(t, err) + require.Len(t, sessions, len(deposits)) + require.Len(t, nonces, len(deposits)) + require.Len(t, signer.createArgs, len(deposits)) + + for i, d := range deposits { + require.NotNil(t, sessions[i]) + require.True(t, bytes.Equal(nonces[i], sessions[i].PublicNonce[:])) + + staticAddress, err := d.GetStaticAddressScript() + require.NoError(t, err) + taprootRoot := staticAddress.TimeoutLeaf.TapHash() + + call := signer.createArgs[i] + require.Equal(t, input.MuSig2Version100RC2, call.version) + require.Equal(t, d.AddressParams.KeyLocator, call.keyLocator) + require.Equal(t, [][]byte{ + d.AddressParams.ClientPubkey.SerializeCompressed(), + d.AddressParams.ServerPubkey.SerializeCompressed(), + }, call.signers) + require.Equal(t, taprootRoot[:], call.taprootTweak) + require.False(t, call.keySpendOnly) + } +} + +func TestCreateMusig2SessionsCleansUpPartialFailure(t *testing.T) { clientKey, err := btcec.NewPrivateKey() require.NoError(t, err) serverKey, err := btcec.NewPrivateKey() @@ -207,34 +343,85 @@ func TestCreateMusig2Sessions_Multiple(t *testing.T) { ClientPubkey: clientKey.PubKey(), ServerPubkey: serverKey.PubKey(), Expiry: 12, - PkScript: []byte{0xaa}, KeyLocator: keychain.KeyLocator{Family: 9, Index: 8}, } + deposits := []*deposit.Deposit{ + {AddressParams: params}, + {AddressParams: params}, + } - staticAddr, err := script.NewStaticAddress( - input.MuSig2Version100RC2, int64(params.Expiry), params.ClientPubkey, params.ServerPubkey, - ) + signer := &sessionCleanupSigner{failCreateAt: 2} + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, _, err = CreateMusig2Sessions(ctx, signer, deposits) + require.ErrorContains(t, err, "session creation failed") + require.Equal(t, [][32]byte{{1}}, signer.cleaned) + require.Equal(t, []error{nil}, signer.cleanupCtxErr) +} + +func TestCreateMusig2SessionsPerDepositCleansUpPartialFailure( + t *testing.T) { + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() require.NoError(t, err) - // Prepare N deposits; only the length matters for session count. + params := &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + Expiry: 12, + KeyLocator: keychain.KeyLocator{Family: 9, Index: 8}, + } deposits := []*deposit.Deposit{ - {OutPoint: wire.OutPoint{Index: 0}}, - {OutPoint: wire.OutPoint{Index: 1}}, - {OutPoint: wire.OutPoint{Index: 2}}, + { + OutPoint: wire.OutPoint{Index: 1}, + AddressParams: params, + }, + { + OutPoint: wire.OutPoint{Index: 2}, + AddressParams: params, + }, } - sessions, nonces, err := CreateMusig2Sessions( - context.Background(), signer, deposits, params, staticAddr, + signer := &sessionCleanupSigner{failCreateAt: 2} + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, _, _, err = CreateMusig2SessionsPerDeposit( + ctx, signer, deposits, ) + require.ErrorContains(t, err, "session creation failed") + require.Equal(t, [][32]byte{{1}}, signer.cleaned) + require.Equal(t, []error{nil}, signer.cleanupCtxErr) +} + +func TestCreateMusig2SessionsPerDepositRejectsDuplicate(t *testing.T) { + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() require.NoError(t, err) - require.Len(t, sessions, len(deposits)) - require.Len(t, nonces, len(deposits)) - // The mock signer returns a zero-value PublicNonce; assert consistency. - for i := range sessions { - require.NotNil(t, sessions[i]) - require.True(t, bytes.Equal(nonces[i], sessions[i].PublicNonce[:])) + params := &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + Expiry: 12, + KeyLocator: keychain.KeyLocator{Family: 9, Index: 8}, + } + outpoint := wire.OutPoint{Index: 1} + deposits := []*deposit.Deposit{ + {OutPoint: outpoint, AddressParams: params}, + {OutPoint: outpoint, AddressParams: params}, } + + signer := &sessionCleanupSigner{} + _, _, _, err = CreateMusig2SessionsPerDeposit( + t.Context(), signer, deposits, + ) + require.ErrorContains(t, err, "duplicate outpoint") + require.Equal(t, 1, signer.createCalls) + require.Equal(t, [][32]byte{{1}}, signer.cleaned) } // makeDeposit creates a deposit with the given value for testing. diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 3a7927a45..002ee25c1 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -536,32 +536,27 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, selectedWithdrawalAmount int64, commitmentType lnrpc.CommitmentType) (*wire.MsgTx, []byte, error) { - // Create a musig2 session for each deposit. - addrParams, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) - if err != nil { - return nil, nil, err - } - - staticAddress, err := m.cfg.AddressManager.GetStaticAddress(ctx) - if err != nil { - return nil, nil, err - } - + // Create a musig2 session for each deposit. Each selected deposit carries + // the address parameters that produced the output, so withdrawals can + // spend inputs from multiple static addresses in one transaction. sessions, clientNonces, idx, err := staticutil.CreateMusig2SessionsPerDeposit( - ctx, m.cfg.Signer, deposits, addrParams, staticAddress, + ctx, m.cfg.Signer, deposits, ) if err != nil { return nil, nil, err } - - params, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) - if err != nil { - return nil, nil, fmt.Errorf("couldn't get confirmation "+ - "height for deposit, %w", err) - } + defer func() { + err := staticutil.CleanupMusig2Sessions( + ctx, m.cfg.Signer, sessions, + ) + if err != nil { + log.Warnf("Unable to clean up withdrawal MuSig2 "+ + "sessions: %v", err) + } + }() outpoints := toOutpoints(deposits) - prevOuts, err := staticutil.ToPrevOuts(deposits, params.PkScript) + prevOuts, err := staticutil.ToPrevOuts(deposits) if err != nil { return nil, nil, err } From 7afd2feee47a097a1d86ad716d1406d3a2c02eb3 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 27 Aug 2026 16:41:07 +0200 Subject: [PATCH 07/30] staticaddr/loopin: send per-deposit address proofs Map every selected outpoint to the static address descriptor that derived its deposit and include those descriptors in loop-in requests. This lets the server validate mixed-address inputs independently of request order. --- staticaddr/loopin/actions.go | 29 ++++--- staticaddr/loopin/actions_test.go | 117 +++++++++++++++++++++++++++- staticaddr/staticutil/utils.go | 46 +++++++++++ staticaddr/staticutil/utils_test.go | 104 +++++++++++++++++++++++++ 4 files changed, 286 insertions(+), 10 deletions(-) diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 78403d212..0417d3b3d 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -158,16 +158,27 @@ func (f *FSM) InitHtlcAction(ctx context.Context, version.CurrentRPCProtocolVersion(), ) + depositDescriptors, err := staticutil.DepositAddressDescriptors( + f.loopIn.Deposits, + ) + if err != nil { + err = fmt.Errorf("unable to prepare static address input "+ + "proofs: %w", err) + + return returnError(err) + } + loopInReq := &swapserverrpc.ServerStaticAddressLoopInRequest{ - SwapHash: f.loopIn.SwapHash[:], - DepositOutpoints: f.loopIn.DepositOutpoints, - Amount: uint64(f.loopIn.SelectedAmount), - HtlcClientPubKey: f.loopIn.ClientPubkey.SerializeCompressed(), - SwapInvoice: f.loopIn.SwapInvoice, - ProtocolVersion: version.CurrentRPCProtocolVersion(), - UserAgent: loop.UserAgent(f.loopIn.Initiator), - PaymentTimeoutSeconds: f.loopIn.PaymentTimeoutSeconds, - Fast: f.loopIn.Fast, + SwapHash: f.loopIn.SwapHash[:], + DepositOutpoints: f.loopIn.DepositOutpoints, + Amount: uint64(f.loopIn.SelectedAmount), + HtlcClientPubKey: f.loopIn.ClientPubkey.SerializeCompressed(), + SwapInvoice: f.loopIn.SwapInvoice, + ProtocolVersion: version.CurrentRPCProtocolVersion(), + UserAgent: loop.UserAgent(f.loopIn.Initiator), + PaymentTimeoutSeconds: f.loopIn.PaymentTimeoutSeconds, + Fast: f.loopIn.Fast, + DepositToClientPubkeys: depositDescriptors, } if f.loopIn.LastHop != nil { loopInReq.LastHop = f.loopIn.LastHop diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index afc0085d8..095021df2 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -757,6 +757,7 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { t.Parallel() mockLnd := test.NewMockLnd() + _, clientPubkey := test.CreateKey(20) _, serverKey := test.CreateKey(21) server := &mockStaticAddressServer{ @@ -771,6 +772,10 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { Index: 0, }, Value: 500_000, + AddressParams: &script.Parameters{ + ClientPubkey: clientPubkey, + PkScript: []byte{0x51, 0x20, 0x01}, + }, } loopIn := &StaticAddressLoopIn{ @@ -804,7 +809,9 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { require.Equal(t, OnHtlcInitiated, event) require.Nil(t, f.LastActionError) require.NotNil(t, server.request) - + require.EqualValues( + t, swap.StaticAddressKeyFamily, loopIn.HtlcKeyLocator.Family, + ) _, routeHints, _, _, err := swap.DecodeInvoice( mockLnd.ChainParams, server.request.SwapInvoice, ) @@ -813,6 +820,95 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { test.RequireRouteHintsEqual(t, loopIn.RouteHints, routeHints) } +// TestInitHtlcActionSendsDepositAddressDescriptors verifies each selected +// outpoint is associated with the static address descriptor that created it. +func TestInitHtlcActionSendsDepositAddressDescriptors(t *testing.T) { + t.Parallel() + + mockLnd := test.NewMockLnd() + _, clientKeyA := test.CreateKey(24) + _, clientKeyB := test.CreateKey(25) + _, serverKey := test.CreateKey(26) + + server := &mockStaticAddressServer{ + response: testStaticAddressLoopInResponse( + serverKey.SerializeCompressed(), + ), + } + + depositA := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + }, + Value: 300_000, + AddressParams: &script.Parameters{ + ClientPubkey: clientKeyA, + PkScript: []byte{0x51, 0x20, 0x01}, + }, + } + depositB := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 1, + }, + Value: 400_000, + AddressParams: &script.Parameters{ + ClientPubkey: clientKeyB, + PkScript: []byte{0x51, 0x20, 0x02}, + }, + } + + // Keep the request deliberately non-sorted to prove descriptor lookup + // depends on outpoint keys rather than slice order. + deposits := []*deposit.Deposit{depositB, depositA} + loopIn := &StaticAddressLoopIn{ + Deposits: deposits, + DepositOutpoints: []string{ + depositB.String(), depositA.String(), + }, + SelectedAmount: depositA.Value + depositB.Value, + QuotedSwapFee: 1_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + PaymentTimeoutSeconds: 3_600, + } + + f := &FSM{ + StateMachine: &fsm.StateMachine{}, + cfg: &Config{ + Server: server, + 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.NoError(t, f.LastActionError) + require.Equal(t, loopIn.DepositOutpoints, server.request.DepositOutpoints) + require.Len(t, server.request.DepositToClientPubkeys, len(deposits)) + + for _, d := range deposits { + descriptor := server.request.DepositToClientPubkeys[d.String()] + require.NotNil(t, descriptor) + require.Equal( + t, d.AddressParams.ClientPubkey.SerializeCompressed(), + descriptor.GetPubkey(), + ) + require.Equal( + t, d.AddressParams.PkScript, descriptor.GetPkScript(), + ) + } +} + func TestSignHtlcTxActionChecksDepositAvailability(t *testing.T) { dep := &deposit.Deposit{ OutPoint: wire.OutPoint{ @@ -883,6 +979,7 @@ func TestCheckDepositsAvailableRejectsDivergentDepositOutpoints( // update failure must not roll back the action or state transition. func TestInitHtlcActionIgnoresSendUpdateErrorAfterPersistence(t *testing.T) { mockLnd := test.NewMockLnd() + _, clientKey := test.CreateKey(23) _, serverKey := test.CreateKey(22) server := &mockStaticAddressServer{ @@ -897,6 +994,10 @@ func TestInitHtlcActionIgnoresSendUpdateErrorAfterPersistence(t *testing.T) { Index: 0, }, Value: 500_000, + AddressParams: &script.Parameters{ + ClientPubkey: clientKey, + PkScript: []byte{0x51, 0x20, 0x02}, + }, } loopIn := &StaticAddressLoopIn{ @@ -3142,10 +3243,16 @@ func TestInitHtlcActionCancelsInvoiceOnServerError(t *testing.T) { defer cancel() mockLnd := test.NewMockLnd() + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) loopIn := &StaticAddressLoopIn{ Deposits: []*deposit.Deposit{{ Value: 200_000, + AddressParams: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + PkScript: []byte{0x51}, + }, }}, InitiationHeight: uint32(mockLnd.Height), InitiationTime: time.Now(), @@ -3175,6 +3282,7 @@ func TestInitHtlcActionCancelsInvoiceOnServerError(t *testing.T) { // cleanup. event := f.InitHtlcAction(ctx, nil) require.Equal(t, fsm.OnError, event) + require.ErrorContains(t, f.LastActionError, "server rejected swap") select { case hash := <-mockLnd.FailInvoiceChannel: @@ -3192,12 +3300,18 @@ func TestInitHtlcActionCancelsInvoiceOnFeeGuardFailure(t *testing.T) { defer cancel() mockLnd := test.NewMockLnd() + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) serverKey, err := btcec.NewPrivateKey() require.NoError(t, err) loopIn := &StaticAddressLoopIn{ Deposits: []*deposit.Deposit{{ Value: 200_000, + AddressParams: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + PkScript: []byte{0x51}, + }, }}, InitiationHeight: uint32(mockLnd.Height), InitiationTime: time.Now(), @@ -3243,6 +3357,7 @@ func TestInitHtlcActionCancelsInvoiceOnFeeGuardFailure(t *testing.T) { // cancel the invoice on this error path as well. event := f.InitHtlcAction(ctx, nil) require.Equal(t, fsm.OnError, event) + require.ErrorIs(t, f.LastActionError, ErrFeeTooHigh) select { case hash := <-mockLnd.FailInvoiceChannel: diff --git a/staticaddr/staticutil/utils.go b/staticaddr/staticutil/utils.go index 9d2175747..a0ed52ae7 100644 --- a/staticaddr/staticutil/utils.go +++ b/staticaddr/staticutil/utils.go @@ -53,6 +53,52 @@ func ToPrevOuts(deposits []*deposit.Deposit) ( return prevOuts, nil } +// DepositAddressDescriptors maps each deposit outpoint to the static address +// descriptor that derives that output. +// +// The server receives this proof material with swap and withdrawal requests and +// verifies it against the L402's server key and expiry before co-signing any +// input. +func DepositAddressDescriptors(deposits []*deposit.Deposit) ( + map[string]*swapserverrpc.StaticAddressDescriptor, error) { + + descriptors := make( + map[string]*swapserverrpc.StaticAddressDescriptor, len(deposits), + ) + for i, d := range deposits { + if d == nil { + return nil, fmt.Errorf("nil deposit at index %d", i) + } + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address "+ + "parameters for deposit %v", d.OutPoint) + } + if d.AddressParams.ClientPubkey == nil { + return nil, fmt.Errorf("missing static address client "+ + "pubkey for deposit %v", d.OutPoint) + } + if len(d.AddressParams.PkScript) == 0 { + return nil, fmt.Errorf("missing static address pkscript "+ + "for deposit %v", d.OutPoint) + } + + depositKey := d.String() + if _, ok := descriptors[depositKey]; ok { + return nil, fmt.Errorf("duplicate outpoint %v", + depositKey) + } + + descriptors[depositKey] = + &swapserverrpc.StaticAddressDescriptor{ + Pubkey: d.AddressParams.ClientPubkey. + SerializeCompressed(), + PkScript: d.AddressParams.PkScript, + } + } + + return descriptors, 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 98ffd3187..0e84f14f2 100644 --- a/staticaddr/staticutil/utils_test.go +++ b/staticaddr/staticutil/utils_test.go @@ -173,6 +173,110 @@ func TestToPrevOutsMissingAddressParams(t *testing.T) { require.ErrorContains(t, err, "missing static address parameters") } +func TestDepositAddressDescriptors(t *testing.T) { + clientKey1, err := btcec.NewPrivateKey() + require.NoError(t, err) + clientKey2, err := btcec.NewPrivateKey() + require.NoError(t, err) + + d1 := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: mustHash(t, "4444444444444444444444444444444444444444444444444444444444444444"), + Index: 0, + }, + AddressParams: &script.Parameters{ + ClientPubkey: clientKey1.PubKey(), + PkScript: []byte{0x51, 0x20, 0x01}, + }, + } + d2 := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: mustHash(t, "5555555555555555555555555555555555555555555555555555555555555555"), + Index: 1, + }, + AddressParams: &script.Parameters{ + ClientPubkey: clientKey2.PubKey(), + PkScript: []byte{0x51, 0x20, 0x02}, + }, + } + + descriptors, err := DepositAddressDescriptors( + []*deposit.Deposit{d1, d2}, + ) + require.NoError(t, err) + require.Len(t, descriptors, 2) + require.Equal( + t, clientKey1.PubKey().SerializeCompressed(), + descriptors[d1.String()].GetPubkey(), + ) + require.Equal( + t, d1.AddressParams.PkScript, + descriptors[d1.String()].GetPkScript(), + ) + require.Equal( + t, clientKey2.PubKey().SerializeCompressed(), + descriptors[d2.String()].GetPubkey(), + ) + require.Equal( + t, d2.AddressParams.PkScript, + descriptors[d2.String()].GetPkScript(), + ) +} + +func TestDepositAddressDescriptorsRejectsInvalidDeposits(t *testing.T) { + t.Run("nil deposit", func(t *testing.T) { + _, err := DepositAddressDescriptors([]*deposit.Deposit{nil}) + require.ErrorContains(t, err, "nil deposit at index 0") + }) + + t.Run("missing params", func(t *testing.T) { + d := &deposit.Deposit{OutPoint: wire.OutPoint{Index: 1}} + _, err := DepositAddressDescriptors([]*deposit.Deposit{d}) + require.ErrorContains(t, err, "missing static address parameters") + }) + + t.Run("missing client key", func(t *testing.T) { + d := &deposit.Deposit{ + OutPoint: wire.OutPoint{Index: 1}, + AddressParams: &script.Parameters{}, + } + _, err := DepositAddressDescriptors([]*deposit.Deposit{d}) + require.ErrorContains(t, err, "missing static address client pubkey") + }) + + t.Run("duplicate outpoint", func(t *testing.T) { + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + d := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: mustHash(t, "6666666666666666666666666666666666666666666666666666666666666666"), + Index: 1, + }, + AddressParams: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + PkScript: []byte{0x51, 0x20, 0x03}, + }, + } + _, err = DepositAddressDescriptors([]*deposit.Deposit{d, d}) + require.ErrorContains(t, err, "duplicate outpoint") + }) + + t.Run("missing pkscript", func(t *testing.T) { + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + d := &deposit.Deposit{ + OutPoint: wire.OutPoint{Index: 1}, + AddressParams: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + }, + } + _, err = DepositAddressDescriptors([]*deposit.Deposit{d}) + require.ErrorContains(t, err, "missing static address pkscript") + }) +} + func TestGetPrevoutInfo_ConversionAndSorting(t *testing.T) { // Helper to create a hash from string. must := func(s string) chainhash.Hash { From d02f93e09c3ecaa7bb16adcdfa9211d4a1d1cb92 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 14:23:33 +0200 Subject: [PATCH 08/30] staticaddr/withdraw: send per-deposit address proofs Include the derivation key for every withdrawal input in the server request. This lets the server validate and sign withdrawals that combine deposits from multiple derived addresses. --- staticaddr/withdraw/manager.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 002ee25c1..3ee95ce09 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -561,6 +561,12 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, return nil, nil, err } + depositDescriptors, err := staticutil.DepositAddressDescriptors(deposits) + if err != nil { + return nil, nil, fmt.Errorf("unable to prepare static address "+ + "input proofs: %w", err) + } + withdrawalTx, unsignedPsbt, err := m.createWithdrawalTx( ctx, outpoints, deposits, prevOuts, btcutil.Amount(selectedWithdrawalAmount), withdrawalAddress, @@ -579,8 +585,9 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, // nolint:lll sigResp, err := m.cfg.StaticAddressServerClient.ServerPsbtWithdrawDeposits( ctx, &staticaddressrpc.ServerPsbtWithdrawRequest{ - WithdrawalPsbt: unsignedPsbt, - DepositToNonces: clientNonces, + WithdrawalPsbt: unsignedPsbt, + DepositToNonces: clientNonces, + DepositToClientPubkeys: depositDescriptors, }, ) if err != nil { From f571b16d58074ec51e6a9851b9ef5c0c5bf042ee Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 27 Aug 2026 10:45:06 +0200 Subject: [PATCH 09/30] staticaddr/deposit: restore owning address parameters Join restored deposits with their persisted static-address rows and hydrate pre-migration deposits from the legacy root address. Return complete ownership metadata from deposit reads and use each deposit's own expiry during loop-in selection. --- cmd/loop/staticaddr_test.go | 7 +- loopd/swapclient_server.go | 13 +- loopdb/migration_22_test.go | 76 ++++++++++ staticaddr/deposit/manager.go | 7 +- staticaddr/deposit/manager_test.go | 130 ++++++++++++++---- staticaddr/deposit/sql_store.go | 71 +++++----- staticaddr/deposit/sql_store_test.go | 72 ++++++++-- .../loopin/deposit_swaphash_migration_test.go | 2 + staticaddr/loopin/manager.go | 29 ++-- staticaddr/loopin/manager_test.go | 67 ++++++++- .../loopin/selected_amount_migration_test.go | 2 + staticaddr/loopin/sql_store_test.go | 9 ++ staticaddr/loopin/test_helpers_test.go | 47 +++++++ staticaddr/withdraw/sql_store_test.go | 30 +++- 14 files changed, 448 insertions(+), 114 deletions(-) create mode 100644 loopdb/migration_22_test.go create mode 100644 staticaddr/loopin/test_helpers_test.go diff --git a/cmd/loop/staticaddr_test.go b/cmd/loop/staticaddr_test.go index 2cc88ad66..2f7bcfb84 100644 --- a/cmd/loop/staticaddr_test.go +++ b/cmd/loop/staticaddr_test.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" "github.com/stretchr/testify/require" @@ -196,6 +197,9 @@ func TestWarningDepositSelectionMatchesLoopInSelection(t *testing.T) { OutPoint: outpoint, Value: btcutil.Amount(fixture.value), ConfirmationHeight: fixture.confirmationHeight, + AddressParams: &address.Parameters{ + Expiry: csvExpiry, + }, }) } @@ -204,8 +208,7 @@ func TestWarningDepositSelectionMatchesLoopInSelection(t *testing.T) { ) loopInSelected, err := loopin.SelectDeposits( - btcutil.Amount(targetAmount), loopInDeposits, csvExpiry, - blockHeight, + btcutil.Amount(targetAmount), loopInDeposits, blockHeight, ) require.NoError(t, err) diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 77ee59c20..5ae481412 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1105,24 +1105,13 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context, "deposits: %w", err) } - // TODO(hieblmi): add params to deposit for multi-address - // support. - params, err := s.staticAddressManager.GetStaticAddressParameters( - ctx, - ) - if err != nil { - return nil, fmt.Errorf("unable to retrieve static "+ - "address parameters: %w", err) - } - info, err := s.lnd.Client.GetInfo(ctx) if err != nil { return nil, fmt.Errorf("unable to get lnd info: %w", err) } selectedDeposits, err := loopin.SelectDeposits( - selectedAmount, deposits, params.Expiry, - info.BlockHeight, + selectedAmount, deposits, info.BlockHeight, ) if err != nil { return nil, fmt.Errorf("unable to select deposits: %w", diff --git a/loopdb/migration_22_test.go b/loopdb/migration_22_test.go new file mode 100644 index 000000000..f8a9a1d2b --- /dev/null +++ b/loopdb/migration_22_test.go @@ -0,0 +1,76 @@ +package loopdb + +import ( + "database/sql" + "net/http" + "path/filepath" + "testing" + + "github.com/golang-migrate/migrate/v4" + sqlite_migrate "github.com/golang-migrate/migrate/v4/database/sqlite" + "github.com/golang-migrate/migrate/v4/source/httpfs" + "github.com/stretchr/testify/require" + _ "modernc.org/sqlite" +) + +// TestMigration22BackfillsDepositAddressOwnership verifies that the ownership +// migration durably links pre-multi-address deposits to the legacy root static +// address. +func TestMigration22BackfillsDepositAddressOwnership(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "migration-22.db") + db, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + + driver, err := sqlite_migrate.WithInstance( + db, &sqlite_migrate.Config{}, + ) + require.NoError(t, err) + + source, err := httpfs.New( + http.FS(sqlSchemas), "sqlc/migrations", + ) + require.NoError(t, err) + + schemaMigrate, err := migrate.NewWithInstance( + "migrations", source, "sqlc", driver, + ) + require.NoError(t, err) + t.Cleanup(func() { + sourceErr, databaseErr := schemaMigrate.Close() + require.NoError(t, sourceErr) + require.NoError(t, databaseErr) + }) + + require.NoError(t, schemaMigrate.Migrate(21)) + + result, err := db.Exec(` + INSERT INTO static_addresses ( + client_pubkey, server_pubkey, expiry, client_key_family, + client_key_index, pkscript, protocol_version, + initiation_height + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + []byte{1}, []byte{2}, 144, 1, 2, []byte{3}, 0, 100, + ) + require.NoError(t, err) + legacyAddressID, err := result.LastInsertId() + require.NoError(t, err) + + _, err = db.Exec(` + INSERT INTO deposits ( + deposit_id, tx_hash, out_index, amount, + confirmation_height, timeout_sweep_pk_script + ) VALUES (?, ?, ?, ?, ?, ?)`, + make([]byte, 32), make([]byte, 32), 0, 100_000, 200, + []byte{4}, + ) + require.NoError(t, err) + + require.NoError(t, schemaMigrate.Migrate(22)) + + var staticAddressID int64 + err = db.QueryRow( + "SELECT static_address_id FROM deposits", + ).Scan(&staticAddressID) + require.NoError(t, err) + require.Equal(t, legacyAddressID, staticAddressID) +} diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index a67858453..44743ffe6 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -69,8 +69,8 @@ type Manager struct { // mu guards access to the activeDeposits map. mu sync.Mutex - // reconcileMu serializes deposit reconciliation so new deposits are - // discovered and retained exactly once per outpoint. + // reconcileMu serializes startup recovery and deposit reconciliation so + // new deposits are discovered and retained exactly once per outpoint. reconcileMu sync.Mutex // activeDeposits contains all the active static address outputs. @@ -213,6 +213,9 @@ func (m *Manager) notifyActiveDeposits(ctx context.Context, // recoverDeposits recovers static address parameters, previous deposits and // state machines from the database and starts the deposit notifier. func (m *Manager) recoverDeposits(ctx context.Context) error { + m.reconcileMu.Lock() + defer m.reconcileMu.Unlock() + log.Infof("Recovering static address parameters and deposits...") // Recover deposits. diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 6b8c086cb..9cf2a06bf 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -13,11 +13,11 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "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/input" "github.com/lightningnetwork/lnd/lnrpc/chainrpc" "github.com/lightningnetwork/lnd/lnwallet" "github.com/stretchr/testify/mock" @@ -492,6 +492,36 @@ func TestManagerSkipsExpiryNotificationOnReconcileFailure(t *testing.T) { } } +func TestRecoverDepositsKeepsSpentWithdrawing(t *testing.T) { + ctx := context.Background() + + id, err := GetRandomDepositID() + require.NoError(t, err) + + storedDeposit := &Deposit{ + ID: id, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 2, + }, + state: Withdrawing, + Value: btcutil.Amount(100000), + ConfirmationHeight: 42, + } + + testContext := newManagerTestContextWithStoredDeposits( + t, []*Deposit{storedDeposit}, nil, + ) + + err = testContext.manager.recoverDeposits(ctx) + require.NoError(t, err) + + deposits, err := testContext.manager.GetActiveDepositsInState(Withdrawing) + require.NoError(t, err) + require.Len(t, deposits, 1) + require.Equal(t, storedDeposit.OutPoint, deposits[0].OutPoint) +} + // ManagerTestContext is a helper struct that contains all the necessary // components to test the reservation manager. type ManagerTestContext struct { @@ -508,26 +538,9 @@ type ManagerTestContext struct { // newManagerTestContext creates a new test context for the reservation manager. func newManagerTestContext(t *testing.T) *ManagerTestContext { - mockLnd := test.NewMockLnd() - lndContext := test.NewContext(t, mockLnd) - - mockStaticAddressClient := new(mockStaticAddressClient) - mockAddressManager := new(mockAddressManager) - mockStore := new(mockStore) - mockChainNotifier := new(MockChainNotifier) - confChan := make(chan *chainntnfs.TxConfirmation) - confErrChan := make(chan error) - blockChan := make(chan int32) - blockErrChan := make(chan error) - ID, err := GetRandomDepositID() require.NoError(t, err) - keyDescriptor, err := mockLnd.WalletKit.DeriveNextKey( - context.Background(), swap.StaticAddressKeyFamily, - ) - require.NoError(t, err) - utxo := &lnwallet.Utxo{ AddressType: lnwallet.TaprootPubkey, Value: btcutil.Amount(100000), @@ -538,15 +551,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { Index: 0xffffffff, }, } - addrParams := &script.Parameters{ - ID: 1, - ClientPubkey: keyDescriptor.PubKey, - ServerPubkey: defaultServerPubkey, - Expiry: defaultExpiry, - PkScript: utxo.PkScript, - KeyLocator: keyDescriptor.KeyLocator, - ProtocolVersion: version.ProtocolVersion_V0, - } + storedDeposits := []*Deposit{ { ID: ID, @@ -555,10 +560,28 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { Value: utxo.Value, ConfirmationHeight: 3, TimeOutSweepPkScript: []byte{0x42, 0x21, 0x69}, - AddressParams: addrParams, }, } + return newManagerTestContextWithStoredDeposits( + t, storedDeposits, []*lnwallet.Utxo{utxo}, + ) +} + +func newManagerTestContextWithStoredDeposits(t *testing.T, + storedDeposits []*Deposit, utxos []*lnwallet.Utxo) *ManagerTestContext { + + mockLnd := test.NewMockLnd() + lndContext := test.NewContext(t, mockLnd) + + mockStaticAddressClient := new(mockStaticAddressClient) + mockAddressManager := new(mockAddressManager) + mockStore := new(mockStore) + mockChainNotifier := new(MockChainNotifier) + confChan := make(chan *chainntnfs.TxConfirmation) + confErrChan := make(chan error) + blockChan := make(chan int32) + blockErrChan := make(chan error) mockStore.On( "AllDeposits", mock.Anything, ).Return(storedDeposits, nil) @@ -567,11 +590,29 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { "UpdateDeposit", mock.Anything, mock.Anything, ).Return(nil) + staticAddress, addrParams := generateStaticAddress( + context.Background(), mockLnd, lndContext.T, + ) + for _, storedDeposit := range storedDeposits { + if storedDeposit.AddressParams == nil { + storedDeposit.AddressParams = addrParams + } + } + var manager *Manager + + mockAddressManager.On( + "GetStaticAddressParameters", mock.Anything, + ).Return(addrParams, nil) + mockAddressManager.On( "ListUnspent", mock.Anything, mock.Anything, mock.Anything, ).Return(func() []*lnwallet.Utxo { - currentUtxo := *utxo + if len(utxos) != 1 { + return utxos + } + + currentUtxo := *utxos[0] currentHeight := manager.currentHeight.Load() if currentHeight < defaultDepositConfirmations { currentUtxo.Confirmations = 0 @@ -615,6 +656,37 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { blockChan: blockChan, blockErrChan: blockErrChan, } + mockAddressManager.On( + "GetStaticAddress", mock.Anything, + ).Return(staticAddress, nil) return testContext } + +func generateStaticAddress(ctx context.Context, mockLnd *test.LndMockServices, + t *testing.T) (*script.StaticAddress, *script.Parameters) { + + keyDescriptor, err := mockLnd.WalletKit.DeriveNextKey( + ctx, swap.StaticAddressKeyFamily, + ) + require.NoError(t, err) + + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(defaultExpiry), + keyDescriptor.PubKey, defaultServerPubkey, + ) + require.NoError(t, err) + + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + return staticAddress, &script.Parameters{ + ID: 1, + ClientPubkey: keyDescriptor.PubKey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: pkScript, + KeyLocator: keyDescriptor.KeyLocator, + ProtocolVersion: 0, + } +} diff --git a/staticaddr/deposit/sql_store.go b/staticaddr/deposit/sql_store.go index ea7ef82df..2a28db930 100644 --- a/staticaddr/deposit/sql_store.go +++ b/staticaddr/deposit/sql_store.go @@ -47,6 +47,13 @@ func NewSqlStore(db *loopdb.BaseDB) *SqlStore { // CreateDeposit creates a static address deposit record in the database. func (s *SqlStore) CreateDeposit(ctx context.Context, deposit *Deposit) error { + if deposit.AddressParams == nil { + return fmt.Errorf("static address parameters must be set") + } + if deposit.AddressParams.ID <= 0 { + return fmt.Errorf("static address ID must be set") + } + createArgs := sqlc.CreateDepositParams{ DepositID: deposit.ID[:], TxHash: deposit.Hash[:], @@ -54,17 +61,10 @@ func (s *SqlStore) CreateDeposit(ctx context.Context, deposit *Deposit) error { Amount: int64(deposit.Value), ConfirmationHeight: deposit.GetConfirmationHeight(), TimeoutSweepPkScript: deposit.TimeOutSweepPkScript, - StaticAddressID: sql.NullInt32{}, - } - if deposit.AddressParams != nil { - if deposit.AddressParams.ID <= 0 { - return fmt.Errorf("static address ID must be set") - } - - createArgs.StaticAddressID = sql.NullInt32{ + StaticAddressID: sql.NullInt32{ Int32: deposit.AddressParams.ID, Valid: true, - } + }, } updateArgs := sqlc.InsertDepositUpdateParams{ @@ -413,6 +413,11 @@ func toDeposit(row depositRow, lastUpdate sqlc.DepositUpdate) (*Deposit, swapHash = &hash } + if !row.StaticAddressID.Valid || row.StaticAddressID.Int32 <= 0 { + return nil, fmt.Errorf("deposit %x missing static address ID", + row.DepositID) + } + deposit := &Deposit{ ID: id, state: fsm.StateType(lastUpdate.UpdateState), @@ -428,34 +433,32 @@ func toDeposit(row depositRow, lastUpdate sqlc.DepositUpdate) (*Deposit, FinalizedWithdrawalTx: finalizedWithdrawalTx, } - if row.StaticAddressID.Valid { - clientPubkey, err := btcec.ParsePubKey(row.ClientPubkey) - if err != nil { - return nil, err - } + clientPubkey, err := btcec.ParsePubKey(row.ClientPubkey) + if err != nil { + return nil, err + } - serverPubkey, err := btcec.ParsePubKey(row.ServerPubkey) - if err != nil { - return nil, err - } + serverPubkey, err := btcec.ParsePubKey(row.ServerPubkey) + if err != nil { + return nil, err + } - deposit.AddressParams = &script.Parameters{ - ID: row.StaticAddressID.Int32, - ClientPubkey: clientPubkey, - ServerPubkey: serverPubkey, - Expiry: uint32(row.Expiry.Int32), - PkScript: row.Pkscript, - KeyLocator: keychain.KeyLocator{ - Family: keychain.KeyFamily( - row.ClientKeyFamily.Int32, - ), - Index: uint32(row.ClientKeyIndex.Int32), - }, - ProtocolVersion: version.AddressProtocolVersion( - row.ProtocolVersion.Int32, + deposit.AddressParams = &script.Parameters{ + ID: row.StaticAddressID.Int32, + ClientPubkey: clientPubkey, + ServerPubkey: serverPubkey, + Expiry: uint32(row.Expiry.Int32), + PkScript: row.Pkscript, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily( + row.ClientKeyFamily.Int32, ), - InitiationHeight: row.InitiationHeight.Int32, - } + Index: uint32(row.ClientKeyIndex.Int32), + }, + ProtocolVersion: version.AddressProtocolVersion( + row.ProtocolVersion.Int32, + ), + InitiationHeight: row.InitiationHeight.Int32, } return deposit, nil diff --git a/staticaddr/deposit/sql_store_test.go b/staticaddr/deposit/sql_store_test.go index e6c2f814f..3a1e182c6 100644 --- a/staticaddr/deposit/sql_store_test.go +++ b/staticaddr/deposit/sql_store_test.go @@ -29,6 +29,12 @@ func TestCreateDepositRejectsUnpersistedAddress(t *testing.T) { require.ErrorContains(t, err, "static address ID must be set") } +func TestCreateDepositRejectsMissingAddress(t *testing.T) { + store := NewSqlStore(nil) + err := store.CreateDeposit(context.Background(), &Deposit{}) + require.ErrorContains(t, err, "static address parameters must be set") +} + // TestDepositAddressOwnershipRoundTrip asserts that every deposit read path // restores the static address parameters referenced by the deposit row. func TestDepositAddressOwnershipRoundTrip(t *testing.T) { @@ -125,6 +131,45 @@ func TestToDeposit(t *testing.T) { tx := wire.NewMsgTx(2) txHash := tx.TxHash() + _, clientPubkey := test.CreateKey(3) + _, serverPubkey := test.CreateKey(4) + + validRow := sqlc.AllDepositsRow{ + DepositID: depositID[:], + TxHash: txHash[:], + Amount: 100000000, + ConfirmationHeight: 123456, + StaticAddressID: sql.NullInt32{ + Int32: 1, + Valid: true, + }, + ClientPubkey: clientPubkey.SerializeCompressed(), + ServerPubkey: serverPubkey.SerializeCompressed(), + Expiry: sql.NullInt32{ + Int32: 144, + Valid: true, + }, + ClientKeyFamily: sql.NullInt32{ + Int32: 123, + Valid: true, + }, + ClientKeyIndex: sql.NullInt32{ + Int32: 456, + Valid: true, + }, + Pkscript: []byte{0x51, 0x20, 0x01}, + ProtocolVersion: sql.NullInt32{ + Valid: true, + }, + InitiationHeight: sql.NullInt32{ + Int32: 789, + Valid: true, + }, + } + validRowWithSwap := validRow + validRowWithSwap.SwapHash = swapHash[:] + rowWithoutAddress := validRow + rowWithoutAddress.StaticAddressID = sql.NullInt32{} tests := []struct { name string @@ -133,32 +178,29 @@ func TestToDeposit(t *testing.T) { expectErr bool }{ { - name: "fully valid data", - row: sqlc.AllDepositsRow{ - DepositID: depositID[:], - TxHash: txHash[:], - Amount: 100000000, - ConfirmationHeight: 123456, - SwapHash: swapHash[:], - }, + name: "valid data with swap", + row: validRowWithSwap, lastUpdate: sqlc.DepositUpdate{ UpdateState: "completed", }, expectErr: false, }, { - name: "fully valid data", - row: sqlc.AllDepositsRow{ - DepositID: depositID[:], - TxHash: txHash[:], - Amount: 100000000, - ConfirmationHeight: 123456, - }, + name: "valid data without swap", + row: validRow, lastUpdate: sqlc.DepositUpdate{ UpdateState: "completed", }, expectErr: false, }, + { + name: "missing static address ownership", + row: rowWithoutAddress, + lastUpdate: sqlc.DepositUpdate{ + UpdateState: "completed", + }, + expectErr: true, + }, } for _, test := range tests { diff --git a/staticaddr/loopin/deposit_swaphash_migration_test.go b/staticaddr/loopin/deposit_swaphash_migration_test.go index ab93a10d2..22539ab40 100644 --- a/staticaddr/loopin/deposit_swaphash_migration_test.go +++ b/staticaddr/loopin/deposit_swaphash_migration_test.go @@ -68,6 +68,8 @@ func TestDepositSwapHashMigration(t *testing.T) { }, } + setPersistedTestDepositAddress(t, ctxb, testDb.BaseDB, d1, d2) + err := depositStore.CreateDeposit(ctxb, d1) require.NoError(t, err) err = depositStore.CreateDeposit(ctxb, d2) diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 2f1e0e778..226b89006 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -678,19 +678,8 @@ func (m *Manager) initiateLoopIn(ctx context.Context, "deposits: %w", err) } - // TODO(hieblmi): add params to deposit for multi-address - // support. - params, err := m.cfg.AddressManager.GetStaticAddressParameters( - ctx, - ) - if err != nil { - return nil, fmt.Errorf("unable to retrieve static "+ - "address parameters: %w", err) - } - selectedDeposits, err = SelectDeposits( - req.SelectedAmount, allDeposits, params.Expiry, - m.currentHeight.Load(), + req.SelectedAmount, allDeposits, m.currentHeight.Load(), ) if err != nil { return nil, fmt.Errorf("unable to select deposits: %w", @@ -870,15 +859,21 @@ func (m *Manager) activeDepositsForLoopIn(loopIn *StaticAddressLoopIn) ( // leaving a dust change. It returns an error if the sum of deposits minus dust // is less than the requested amount. func SelectDeposits(targetAmount btcutil.Amount, - unfilteredDeposits []*deposit.Deposit, csvExpiry uint32, - blockHeight uint32) ([]*deposit.Deposit, error) { + unfilteredDeposits []*deposit.Deposit, blockHeight uint32) ( + []*deposit.Deposit, error) { // Filter out deposits that are too close to expiry to be swapped. var deposits []*deposit.Deposit for _, d := range unfilteredDeposits { confirmationHeight := d.GetConfirmationHeight() + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address parameters "+ + "for deposit %s", d.OutPoint.String()) + } + if !IsSwappable( - uint32(confirmationHeight), blockHeight, csvExpiry, + uint32(confirmationHeight), blockHeight, + d.AddressParams.Expiry, ) { log.Debugf("Skipping deposit %s as it expires before "+ @@ -905,11 +900,11 @@ func SelectDeposits(targetAmount btcutil.Amount, if deposits[i].Value == deposits[j].Value { iExp := blocksUntilDepositExpiry( uint32(iConfirmationHeight), blockHeight, - csvExpiry, + deposits[i].AddressParams.Expiry, ) jExp := blocksUntilDepositExpiry( uint32(jConfirmationHeight), blockHeight, - csvExpiry, + deposits[j].AddressParams.Expiry, ) return iExp < jExp diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index 9fa8587c2..e4059a8f6 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -14,6 +14,7 @@ import ( "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/labels" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swap" @@ -193,9 +194,11 @@ func TestSelectDeposits(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + setTestDepositParams(tc.deposits, tc.csvExpiry) + setTestDepositParams(tc.expected, tc.csvExpiry) + selectedDeposits, err := SelectDeposits( - tc.targetValue, tc.deposits, tc.csvExpiry, - tc.blockHeight, + tc.targetValue, tc.deposits, tc.blockHeight, ) if tc.expectedErr == "" { require.NoError(t, err) @@ -207,6 +210,58 @@ func TestSelectDeposits(t *testing.T) { } } +// TestSelectDepositsUsesPerDepositExpiry verifies that deposit filtering and +// tie-breaking use the expiry of the address that owns each deposit. +func TestSelectDepositsUsesPerDepositExpiry(t *testing.T) { + const ( + blockHeight = uint32(2_000) + confirmationHeight = int64(1_000) + ) + + newDeposit := func(id byte, value btcutil.Amount, + expiry uint32) *deposit.Deposit { + + return &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{id}, + }, + Value: value, + ConfirmationHeight: confirmationHeight, + AddressParams: &address.Parameters{ + Expiry: expiry, + }, + } + } + + t.Run("filter", func(t *testing.T) { + // The larger deposit has only 1,000 blocks left and is not + // swappable. The smaller deposit has 1,050 blocks left and is + // exactly at the loop-in CLTV delta plus its safety buffer. + tooClose := newDeposit(1, 3_000_000, 2_000) + eligible := newDeposit(2, 2_000_000, 2_050) + + selected, err := SelectDeposits( + 1_000_000, []*deposit.Deposit{tooClose, eligible}, + blockHeight, + ) + require.NoError(t, err) + require.Equal(t, []*deposit.Deposit{eligible}, selected) + }) + + t.Run("tie break", func(t *testing.T) { + laterExpiry := newDeposit(3, 3_000_000, 2_200) + earlierExpiry := newDeposit(4, 3_000_000, 2_100) + + selected, err := SelectDeposits( + 1_000_000, + []*deposit.Deposit{laterExpiry, earlierExpiry}, + blockHeight, + ) + require.NoError(t, err) + require.Equal(t, []*deposit.Deposit{earlierExpiry}, selected) + }) +} + // TestInitiateLoopInAllowsReservedAutoloopLabel verifies that the internal // loop-in manager path does not reject reserved autoloop labels. The RPC // boundary owns that validation, while internal autoloop dispatch must be able @@ -417,6 +472,14 @@ func TestGetAllSwapsPreservesStoreDeposits(t *testing.T) { require.Equal(t, []*deposit.Deposit{currentDeposit}, swaps[0].Deposits) } +func setTestDepositParams(deposits []*deposit.Deposit, expiry uint32) { + for _, d := range deposits { + d.AddressParams = &address.Parameters{ + Expiry: expiry, + } + } +} + // TestIsSwappableUnconfirmed checks that an unconfirmed deposit is considered // swappable because its CSV timeout has not started yet. func TestIsSwappableUnconfirmed(t *testing.T) { diff --git a/staticaddr/loopin/selected_amount_migration_test.go b/staticaddr/loopin/selected_amount_migration_test.go index b68269fc0..da60cb46d 100644 --- a/staticaddr/loopin/selected_amount_migration_test.go +++ b/staticaddr/loopin/selected_amount_migration_test.go @@ -62,6 +62,8 @@ func TestMigrateSelectedSwapAmount(t *testing.T) { }, } + setPersistedTestDepositAddress(t, ctxb, testDb.BaseDB, d1, d2) + err := depositStore.CreateDeposit(ctxb, d1) require.NoError(t, err) err = depositStore.CreateDeposit(ctxb, d2) diff --git a/staticaddr/loopin/sql_store_test.go b/staticaddr/loopin/sql_store_test.go index bdea7e5b4..768d6c4ad 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -190,6 +190,10 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) { }, } + setPersistedTestDepositAddress( + t, ctxb, testDb.BaseDB, d1, d2, d3, d4, + ) + err := depositStore.CreateDeposit(ctxb, d1) require.NoError(t, err) err = depositStore.CreateDeposit(ctxb, d2) @@ -394,6 +398,8 @@ func TestCreateLoopIn(t *testing.T) { }, } + setPersistedTestDepositAddress(t, ctx, testDb.BaseDB, d1, d2) + err := depositStore.CreateDeposit(ctx, d1) require.NoError(t, err) err = depositStore.CreateDeposit(ctx, d2) @@ -606,6 +612,8 @@ func TestGetLoopInByHashOrdersDepositsBySnapshot(t *testing.T) { }, } + setPersistedTestDepositAddress(t, ctx, testDb.BaseDB, d1, d2) + require.NoError(t, depositStore.CreateDeposit(ctx, d1)) require.NoError(t, depositStore.CreateDeposit(ctx, d2)) @@ -679,6 +687,7 @@ func TestGetLoopInByHashPreservesStoredDepositOutpoints(t *testing.T) { 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x41, }, } + setPersistedTestDepositAddress(t, ctxb, testDb.BaseDB, d) require.NoError(t, depositStore.CreateDeposit(ctxb, d)) d.SetState(deposit.LoopingIn) diff --git a/staticaddr/loopin/test_helpers_test.go b/staticaddr/loopin/test_helpers_test.go new file mode 100644 index 000000000..f24f11576 --- /dev/null +++ b/staticaddr/loopin/test_helpers_test.go @@ -0,0 +1,47 @@ +package loopin + +import ( + "context" + "testing" + + "github.com/lightninglabs/loop/loopdb" + "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/test" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +func setPersistedTestDepositAddress(t *testing.T, ctx context.Context, + db *loopdb.BaseDB, deposits ...*deposit.Deposit) { + + t.Helper() + + _, clientPubkey := test.CreateKey(101) + _, serverPubkey := test.CreateKey(102) + params := &script.Parameters{ + ClientPubkey: clientPubkey, + ServerPubkey: serverPubkey, + Expiry: 144, + KeyLocator: keychain.KeyLocator{ + Family: 123, + Index: 456, + }, + PkScript: []byte{0x51, 0x20, 0x01}, + ProtocolVersion: version.ProtocolVersion_V0, + InitiationHeight: 789, + } + + addressStore := address.NewSqlStore(db) + require.NoError(t, addressStore.CreateStaticAddress(ctx, params)) + + var err error + params.ID, err = addressStore.GetStaticAddressID(ctx, params.PkScript) + require.NoError(t, err) + + for _, d := range deposits { + d.AddressParams = params + } +} diff --git a/staticaddr/withdraw/sql_store_test.go b/staticaddr/withdraw/sql_store_test.go index 5897f20ef..2b4c9018b 100644 --- a/staticaddr/withdraw/sql_store_test.go +++ b/staticaddr/withdraw/sql_store_test.go @@ -7,7 +7,12 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/loopdb" + "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/test" + "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/require" ) @@ -41,6 +46,29 @@ func TestSqlStore(t *testing.T) { 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x4d, }, } + _, clientPubkey := test.CreateKey(101) + _, serverPubkey := test.CreateKey(102) + addressParams := &script.Parameters{ + ClientPubkey: clientPubkey, + ServerPubkey: serverPubkey, + Expiry: 144, + KeyLocator: keychain.KeyLocator{ + Family: 123, + Index: 456, + }, + PkScript: []byte{0x51, 0x20, 0x01}, + ProtocolVersion: version.ProtocolVersion_V0, + InitiationHeight: 789, + } + addressStore := address.NewSqlStore(testDb.BaseDB) + err := addressStore.CreateStaticAddress(ctxb, addressParams) + require.NoError(t, err) + addressParams.ID, err = addressStore.GetStaticAddressID( + ctxb, addressParams.PkScript, + ) + require.NoError(t, err) + d1.AddressParams = addressParams + d2.AddressParams = addressParams withdrawalTx := &wire.MsgTx{ Version: 2, @@ -60,7 +88,7 @@ func TestSqlStore(t *testing.T) { }, } - err := depositStore.CreateDeposit(ctxb, d1) + err = depositStore.CreateDeposit(ctxb, d1) require.NoError(t, err) err = depositStore.CreateDeposit(ctxb, d2) require.NoError(t, err) From 2481249ebfe4ed615594f17d1ccdfd07a2ca3fd2 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 28 Aug 2026 12:43:50 +0200 Subject: [PATCH 10/30] staticaddr/loopin: persist change addresses Associate fractional loop-ins with their operation-specific static change address so recovery restores the descriptor needed to reconstruct signed transactions. Backfill legacy fractional swaps to the original address. --- loopdb/migration_23_test.go | 93 ++++++++++++++ ...0023_static_loopin_change_address.down.sql | 1 + ...000023_static_loopin_change_address.up.sql | 13 ++ loopdb/sqlc/models.go | 1 + loopdb/sqlc/queries/static_address_loopin.sql | 33 ++++- loopdb/sqlc/static_address_loopin.sql.go | 74 +++++++++++- staticaddr/loopin/loopin.go | 6 + staticaddr/loopin/sql_store.go | 56 +++++++++ staticaddr/loopin/sql_store_test.go | 113 ++++++++++++++++++ 9 files changed, 379 insertions(+), 11 deletions(-) create mode 100644 loopdb/migration_23_test.go create mode 100644 loopdb/sqlc/migrations/000023_static_loopin_change_address.down.sql create mode 100644 loopdb/sqlc/migrations/000023_static_loopin_change_address.up.sql diff --git a/loopdb/migration_23_test.go b/loopdb/migration_23_test.go new file mode 100644 index 000000000..f0c6f132a --- /dev/null +++ b/loopdb/migration_23_test.go @@ -0,0 +1,93 @@ +package loopdb + +import ( + "database/sql" + "net/http" + "path/filepath" + "testing" + + "github.com/golang-migrate/migrate/v4" + sqlite_migrate "github.com/golang-migrate/migrate/v4/database/sqlite" + "github.com/golang-migrate/migrate/v4/source/httpfs" + "github.com/stretchr/testify/require" + _ "modernc.org/sqlite" +) + +// TestMigration23BackfillsLoopInChangeAddress verifies that existing +// fractional loop-ins remain tied to the legacy root address they used for +// change before per-swap change addresses were introduced. +func TestMigration23BackfillsLoopInChangeAddress(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "migration-23.db") + db, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + + driver, err := sqlite_migrate.WithInstance( + db, &sqlite_migrate.Config{}, + ) + require.NoError(t, err) + + source, err := httpfs.New( + http.FS(sqlSchemas), "sqlc/migrations", + ) + require.NoError(t, err) + + schemaMigrate, err := migrate.NewWithInstance( + "migrations", source, "sqlc", driver, + ) + require.NoError(t, err) + t.Cleanup(func() { + sourceErr, databaseErr := schemaMigrate.Close() + require.NoError(t, sourceErr) + require.NoError(t, databaseErr) + }) + + require.NoError(t, schemaMigrate.Migrate(22)) + + result, err := db.Exec(` + INSERT INTO static_addresses ( + client_pubkey, server_pubkey, expiry, client_key_family, + client_key_index, pkscript, protocol_version, + initiation_height + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + []byte{1}, []byte{2}, 144, 1, 2, []byte{3}, 0, 100, + ) + require.NoError(t, err) + legacyAddressID, err := result.LastInsertId() + require.NoError(t, err) + + // Add a newer address to prove that the migration selects the original + // legacy root rather than whichever address was inserted most recently. + _, err = db.Exec(` + INSERT INTO static_addresses ( + client_pubkey, server_pubkey, expiry, client_key_family, + client_key_index, pkscript, protocol_version, + initiation_height + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + []byte{4}, []byte{5}, 144, 1, 3, []byte{6}, 0, 101, + ) + require.NoError(t, err) + + swapHash := []byte{7} + _, err = db.Exec(` + INSERT INTO static_address_swaps ( + swap_hash, swap_invoice, payment_timeout_seconds, + quoted_swap_fee_satoshis, deposit_outpoints, + htlc_tx_fee_rate_sat_kw, htlc_timeout_sweep_address, + selected_amount + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + swapHash, "invoice", 3600, 1000, "txid:0", 2500, + "bcrt1qexample", 60_000, + ) + require.NoError(t, err) + + require.NoError(t, schemaMigrate.Migrate(23)) + + var changeAddressID int64 + err = db.QueryRow(` + SELECT change_static_address_id + FROM static_address_swaps + WHERE swap_hash = ?`, swapHash, + ).Scan(&changeAddressID) + require.NoError(t, err) + require.Equal(t, legacyAddressID, changeAddressID) +} 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 000000000..8a0180293 --- /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 000000000..6383bf766 --- /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/models.go b/loopdb/sqlc/models.go index 34a924256..bb4244ea2 100644 --- a/loopdb/sqlc/models.go +++ b/loopdb/sqlc/models.go @@ -152,6 +152,7 @@ type StaticAddressSwap struct { Fast bool ConfirmationRiskDecision string ConfirmationRiskDecisionTime sql.NullTime + ChangeStaticAddressID sql.NullInt32 } type StaticAddressSwapUpdate struct { diff --git a/loopdb/sqlc/queries/static_address_loopin.sql b/loopdb/sqlc/queries/static_address_loopin.sql index ecd252f6d..e38b0b405 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,7 +23,8 @@ INSERT INTO static_address_swaps ( $8, $9, $10, - $11 + $11, + $12 ); -- name: UpdateStaticAddressLoopIn :exec @@ -64,13 +66,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 +91,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,4 +194,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 8cb2aef6d..6c297f323 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, + 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,7 @@ type GetStaticAddressLoopInSwapRow struct { Fast bool ConfirmationRiskDecision string ConfirmationRiskDecisionTime sql.NullTime + ChangeStaticAddressID sql.NullInt32 SwapHash_3 []byte SenderScriptPubkey []byte ReceiverScriptPubkey []byte @@ -225,6 +237,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 +276,7 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt &i.Fast, &i.ConfirmationRiskDecision, &i.ConfirmationRiskDecisionTime, + &i.ChangeStaticAddressID, &i.SwapHash_3, &i.SenderScriptPubkey, &i.ReceiverScriptPubkey, @@ -263,6 +284,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 +299,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, + 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 +359,7 @@ type GetStaticAddressLoopInSwapsByStatesRow struct { Fast bool ConfirmationRiskDecision string ConfirmationRiskDecisionTime sql.NullTime + ChangeStaticAddressID sql.NullInt32 SwapHash_3 []byte SenderScriptPubkey []byte ReceiverScriptPubkey []byte @@ -326,6 +367,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 +412,7 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla &i.Fast, &i.ConfirmationRiskDecision, &i.ConfirmationRiskDecisionTime, + &i.ChangeStaticAddressID, &i.SwapHash_3, &i.SenderScriptPubkey, &i.ReceiverScriptPubkey, @@ -370,6 +420,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 +454,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 +467,8 @@ INSERT INTO static_address_swaps ( $8, $9, $10, - $11 + $11, + $12 ) ` @@ -424,6 +484,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 +500,7 @@ func (q *Queries) InsertStaticAddressLoopIn(ctx context.Context, arg InsertStati arg.HtlcTimeoutSweepTxID, arg.HtlcTimeoutSweepAddress, arg.Fast, + arg.ChangeStaticAddressID, ) return err } diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index 208fa4827..f736c516c 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -17,6 +17,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" @@ -165,6 +166,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. diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index cd102850b..713f2dd34 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{ @@ -638,6 +650,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, @@ -671,6 +688,7 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, HtlcTimeoutSweepAddress: timeoutAddress, HtlcTimeoutSweepTxHash: htlcTimeoutSweepTxHash, Deposits: depositList, + ChangeAddressParams: changeAddressParams, } if swap.ConfirmationRiskDecisionTime.Valid { loopIn.ConfirmationRiskDecisionTime = @@ -719,3 +737,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 768d6c4ad..730048dc3 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" @@ -21,6 +22,118 @@ import ( "github.com/stretchr/testify/require" ) +// TestLoopInChangeAddressRoundTrip verifies that a generated per-swap change +// address survives both direct lookup and state-based recovery. +func TestLoopInChangeAddressRoundTrip(t *testing.T) { + ctx := t.Context() + testDB := loopdb.NewTestDB(t) + defer testDB.Close() + + testClock := clock.NewTestClock(time.Now()) + depositStore := deposit.NewSqlStore(testDB.BaseDB) + loopInStore := NewSqlStore( + loopdb.NewTypedStore[Querier](testDB), testClock, + &chaincfg.RegressionNetParams, + ) + addressStore := address.NewSqlStore(testDB.BaseDB) + + depositID, err := deposit.GetRandomDepositID() + require.NoError(t, err) + ownedDeposit := &deposit.Deposit{ + ID: depositID, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 2, + }, + Value: 100_000, + TimeOutSweepPkScript: []byte{0x00, 0x14, 0x03}, + } + setPersistedTestDepositAddress( + t, ctx, testDB.BaseDB, ownedDeposit, + ) + require.NoError(t, depositStore.CreateDeposit(ctx, ownedDeposit)) + ownedDeposit.SetState(deposit.LoopingIn) + require.NoError(t, depositStore.UpdateDeposit(ctx, ownedDeposit)) + + _, changeClientPubkey := test.CreateKey(1) + _, changeServerPubkey := test.CreateKey(2) + changeParams := &address.Parameters{ + ClientPubkey: changeClientPubkey, + ServerPubkey: changeServerPubkey, + Expiry: 288, + KeyLocator: keychain.KeyLocator{ + Family: 321, + Index: 654, + }, + PkScript: []byte{0x51, 0x20, 0x04}, + ProtocolVersion: version.ProtocolVersion_V0, + InitiationHeight: 987, + } + require.NoError( + t, addressStore.CreateStaticAddress(ctx, changeParams), + ) + changeParams.ID, err = addressStore.GetStaticAddressID( + ctx, changeParams.PkScript, + ) + require.NoError(t, err) + + _, swapClientPubkey := test.CreateKey(3) + _, swapServerPubkey := test.CreateKey(4) + timeoutAddress, err := btcutil.DecodeAddress(P2wkhAddr, nil) + require.NoError(t, err) + + swapHash := lntypes.Hash{5, 6, 7, 8} + swap := &StaticAddressLoopIn{ + SwapHash: swapHash, + SwapPreimage: lntypes.Preimage{9, 10, 11, 12}, + DepositOutpoints: []string{ownedDeposit.OutPoint.String()}, + Deposits: []*deposit.Deposit{ownedDeposit}, + SelectedAmount: 60_000, + ClientPubkey: swapClientPubkey, + ServerPubkey: swapServerPubkey, + HtlcTimeoutSweepAddress: timeoutAddress, + ChangeAddressParams: changeParams, + } + swap.SetState(SignHtlcTx) + require.NoError(t, loopInStore.CreateLoopIn(ctx, swap)) + + assertChangeAddress := func(t *testing.T, + got *address.Parameters) { + + t.Helper() + require.NotNil(t, got) + require.Equal(t, changeParams.ID, got.ID) + require.Equal( + t, changeParams.ClientPubkey.SerializeCompressed(), + got.ClientPubkey.SerializeCompressed(), + ) + require.Equal( + t, changeParams.ServerPubkey.SerializeCompressed(), + got.ServerPubkey.SerializeCompressed(), + ) + require.Equal(t, changeParams.Expiry, got.Expiry) + require.Equal(t, changeParams.KeyLocator, got.KeyLocator) + require.Equal(t, changeParams.PkScript, got.PkScript) + require.Equal( + t, changeParams.ProtocolVersion, got.ProtocolVersion, + ) + require.Equal( + t, changeParams.InitiationHeight, got.InitiationHeight, + ) + } + + restoredSwap, err := loopInStore.GetLoopInByHash(ctx, swapHash) + require.NoError(t, err) + assertChangeAddress(t, restoredSwap.ChangeAddressParams) + + recoveredSwaps, err := loopInStore.GetStaticAddressLoopInSwapsByStates( + ctx, []fsm.StateType{SignHtlcTx}, + ) + require.NoError(t, err) + require.Len(t, recoveredSwaps, 1) + assertChangeAddress(t, recoveredSwaps[0].ChangeAddressParams) +} + // TestLoopInDepositAddressOwnershipRoundTrip asserts that deposits restored as // part of a loop-in retain the static address parameters needed for signing. func TestLoopInDepositAddressOwnershipRoundTrip(t *testing.T) { From bdca3c26a6dd79df42e8a0b109aca2127c0f2ccf Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 28 Aug 2026 12:44:08 +0200 Subject: [PATCH 11/30] staticaddr/loopin: use generated change addresses Create a fresh static address for fractional loop-in change and send its descriptor to the server. Reconstruct signed HTLCs with the persisted parameters and verify cooperative batch change by output script. --- staticaddr/loopin/actions.go | 24 ++++++++ staticaddr/loopin/actions_test.go | 85 +++++++++++++++++++++++++++++ staticaddr/loopin/interface.go | 5 ++ staticaddr/loopin/loopin.go | 31 +++++++++-- staticaddr/loopin/loopin_test.go | 5 +- staticaddr/loopin/manager.go | 76 +++++++++++++++++--------- staticaddr/loopin/manager_test.go | 83 ++++++++++++++++++++-------- staticaddr/staticutil/utils.go | 30 ++++++++++ staticaddr/staticutil/utils_test.go | 39 +++++++++++++ 9 files changed, 321 insertions(+), 57 deletions(-) diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 0417d3b3d..fe9743a31 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -109,6 +109,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 +202,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, PaymentTimeoutSeconds: f.loopIn.PaymentTimeoutSeconds, Fast: f.loopIn.Fast, DepositToClientPubkeys: depositDescriptors, + ChangeOutput: changeOutput, } if f.loopIn.LastHop != nil { loopInReq.LastHop = f.loopIn.LastHop diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 095021df2..f99d77555 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -12,6 +12,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/version" @@ -1018,6 +1019,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{}, @@ -1041,6 +1043,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 @@ -3456,6 +3534,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 d54355a7b..aa54e7277 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 f736c516c..cc811ad3f 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -311,11 +311,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. @@ -351,9 +350,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, }) } @@ -424,9 +428,10 @@ func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context, // 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 len(htlcTx.TxOut) == 2 && l.ChangeAddressParams != nil { if bytes.Equal( - htlcTx.TxOut[0].PkScript, l.AddressParams.PkScript, + htlcTx.TxOut[0].PkScript, + l.ChangeAddressParams.PkScript, ) { return nil, fmt.Errorf("htlc tx output layout " + @@ -513,6 +518,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 8b0892e66..7af946037 100644 --- a/staticaddr/loopin/loopin_test.go +++ b/staticaddr/loopin/loopin_test.go @@ -77,7 +77,8 @@ func TestCreateHtlcSweepTxSweepValue(t *testing.T) { Hash: chainhash.Hash{0xaa}, Index: 0, }, - Value: depositValue, + Value: depositValue, + AddressParams: addrParams, }, } @@ -96,7 +97,7 @@ func TestCreateHtlcSweepTxSweepValue(t *testing.T) { ClientPubkey: clientKey.PubKey(), ServerPubkey: serverKey.PubKey(), Deposits: deposits, - AddressParams: addrParams, + ChangeAddressParams: addrParams, HtlcTxFeeRate: feeRate, SelectedAmount: selectedAmount, PaymentTimeoutSeconds: 3600, diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 226b89006..f9759042e 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 } @@ -466,12 +465,11 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, return err } -// checkChange ensures that the server sends us the correct change amount -// back to our static address. An edge case arises if a batch contains two -// 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. +// checkChange ensures that the server sends us the correct change amount back +// to our static addresses. The server consolidates change by output script, so +// we mirror that behavior when calculating the expected outputs for a 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 +494,66 @@ func (m *Manager) checkChange(ctx context.Context, return err } - var expectedChange btcutil.Amount + expectedChanges := make(map[string]*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) + + pkScript := loopIn.ChangeAddressParams.PkScript + scriptKey := string(pkScript) + expectedChange, ok := expectedChanges[scriptKey] + if ok { + expectedChange.Value += int64(changeAmt) + continue + } + + expectedChanges[scriptKey] = &wire.TxOut{ + Value: int64(changeAmt), + PkScript: bytes.Clone(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) { + for _, expected := range expectedChanges { + var found bool + for _, out := range sweepTx.TxOut { + if out.Value == expected.Value && + bytes.Equal(out.PkScript, expected.PkScript) { + + found = true + break + } + } - // We found the expected change output. - return nil + 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 e4059a8f6..af036762b 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -705,22 +705,25 @@ func makeSweepTx(inputs []wire.OutPoint, outputs []*wire.TxOut) *wire.MsgTx { func TestCheckChange(t *testing.T) { ctx := context.Background() - // Prepare a common change address and an alternate address. + // Prepare shared and per-swap change addresses, plus unrelated outputs. changeAddr := &script.Parameters{PkScript: []byte{0xaa, 0xbb}} + freshChangeAddr := &script.Parameters{PkScript: []byte{0xab, 0xcd}} otherAddr := &script.Parameters{PkScript: []byte{0xcc, 0xdd}} serverAddr := &script.Parameters{PkScript: []byte{0xee, 0xff}} // Prepare swaps (loop-ins) with varying deposit totals and selections. // Helper to make a swap with deposits and selected amount. makeSwap := func(h byte, deposits []*deposit.Deposit, - selected btcutil.Amount) (lntypes.Hash, *StaticAddressLoopIn) { + selected btcutil.Amount, + changeAddress *script.Parameters) (lntypes.Hash, + *StaticAddressLoopIn) { var hash lntypes.Hash hash[0] = h li := &StaticAddressLoopIn{ - Deposits: deposits, - SelectedAmount: selected, - AddressParams: changeAddr, + Deposits: deposits, + SelectedAmount: selected, + ChangeAddressParams: changeAddress, } return hash, li } @@ -732,16 +735,23 @@ func TestCheckChange(t *testing.T) { s2d1 := makeDeposit(2, 0, 1500, confirmationHeight) s3d1 := makeDeposit(3, 0, 800, confirmationHeight) s4d1 := makeDeposit(4, 0, 900, confirmationHeight) + s5d1 := makeDeposit(5, 0, 700, confirmationHeight) // Swaps: // A: total 3000, selected 3000 => no change. - hA, liA := makeSwap(10, []*deposit.Deposit{s1d1, s1d2}, 3000) + hA, liA := makeSwap( + 10, []*deposit.Deposit{s1d1, s1d2}, 3000, changeAddr, + ) // B: total 1500, selected 1000 => change 500. - hB, liB := makeSwap(11, []*deposit.Deposit{s2d1}, 1000) + hB, liB := makeSwap(11, []*deposit.Deposit{s2d1}, 1000, changeAddr) // C: total 800, selected 400 => change 400. - hC, liC := makeSwap(12, []*deposit.Deposit{s3d1}, 400) + hC, liC := makeSwap(12, []*deposit.Deposit{s3d1}, 400, changeAddr) // D: total 900, selected 500 => change 400. - hD, liD := makeSwap(13, []*deposit.Deposit{s4d1}, 500) + hD, liD := makeSwap(13, []*deposit.Deposit{s4d1}, 500, changeAddr) + // E: total 700, selected 400 => change 300 to a fresh address. + hE, liE := makeSwap( + 14, []*deposit.Deposit{s5d1}, 400, freshChangeAddr, + ) // Mapping deposits -> swaps (by deposit IDs). mapIDs := map[lntypes.Hash][]deposit.ID{ @@ -749,6 +759,7 @@ func TestCheckChange(t *testing.T) { hB: {s2d1.ID}, hC: {s3d1.ID}, hD: {s4d1.ID}, + hE: {s5d1.ID}, } loopIns := map[lntypes.Hash]*StaticAddressLoopIn{ @@ -756,6 +767,7 @@ func TestCheckChange(t *testing.T) { hB: liB, hC: liC, hD: liD, + hE: liE, } // Common manager with mocked dependencies; will change inputs per test. @@ -775,7 +787,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 } @@ -791,7 +802,6 @@ func TestCheckChange(t *testing.T) { PkScript: serverAddr.PkScript, }, }, - addr: changeAddr, }, { name: "single swap change present", @@ -806,11 +816,10 @@ 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 + name: "shared script changes aggregated", + inDeps: []*deposit.Deposit{s2d1, s3d1}, // B(500)+C(400) outputs: []*wire.TxOut{ { Value: 1337, @@ -821,11 +830,10 @@ func TestCheckChange(t *testing.T) { 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: "identical shared script changes aggregated", + inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400) outputs: []*wire.TxOut{ { Value: 1337, @@ -836,13 +844,45 @@ func TestCheckChange(t *testing.T) { PkScript: changeAddr.PkScript, }, }, - addr: changeAddr, + }, + { + name: "split shared script changes rejected", + inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400) + outputs: []*wire.TxOut{ + { + Value: 400, + PkScript: changeAddr.PkScript, + }, + { + Value: 400, + PkScript: changeAddr.PkScript, + }, + }, + expectErr: true, + expectedErrMsg: "couldn't find expected change", + }, + { + name: "distinct change scripts remain separate", + inDeps: []*deposit.Deposit{s2d1, s5d1}, // B(500)+E(300) + outputs: []*wire.TxOut{ + { + Value: 1337, + PkScript: serverAddr.PkScript, + }, + { + Value: 500, + PkScript: changeAddr.PkScript, + }, + { + Value: 300, + PkScript: freshChangeAddr.PkScript, + }, + }, }, { 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", }, @@ -859,7 +899,6 @@ func TestCheckChange(t *testing.T) { PkScript: otherAddr.PkScript, }, }, - addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, @@ -876,7 +915,6 @@ func TestCheckChange(t *testing.T) { PkScript: changeAddr.PkScript, }, }, - addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, @@ -897,7 +935,6 @@ func TestCheckChange(t *testing.T) { PkScript: otherAddr.PkScript, }, }, - addr: changeAddr, }, } @@ -920,7 +957,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/staticutil/utils.go b/staticaddr/staticutil/utils.go index a0ed52ae7..ea00777b4 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" @@ -99,6 +100,35 @@ func DepositAddressDescriptors(deposits []*deposit.Deposit) ( return descriptors, 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 0e84f14f2..32cf56f79 100644 --- a/staticaddr/staticutil/utils_test.go +++ b/staticaddr/staticutil/utils_test.go @@ -277,6 +277,45 @@ func TestDepositAddressDescriptorsRejectsInvalidDeposits(t *testing.T) { }) } +func TestChangeOutput(t *testing.T) { + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + params := &script.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(&script.Parameters{}, 100) + require.ErrorContains(t, err, "missing static address change client pubkey") + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + _, err = ChangeOutput(&script.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 { From c6260a0763467a57cc295a137ac68fba98e09583 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 26 Aug 2026 15:53:12 +0200 Subject: [PATCH 12/30] staticaddr/loopin: drop legacy address state Multi-address loop-ins sign and construct transactions from the parameters attached to each deposit and their dedicated change address. The legacy root address fields therefore became write-only, but populating them could still abort signing, sweep handling, or recovery when the root lookup failed. Remove those fields and lookups, select the FSM from the protocol version persisted with the swap, and set that version before constructing new state machines. Keep the root-parameter lookup used by autoloop expiry calculation and add regression coverage for recovery and unsupported persisted versions. --- loopd/swapclient_server_test.go | 3 -- staticaddr/loopin/actions.go | 36 +++++----------------- staticaddr/loopin/actions_test.go | 29 +++++++++--------- staticaddr/loopin/autoloop_test.go | 12 ++++---- staticaddr/loopin/fsm.go | 10 ++----- staticaddr/loopin/fsm_test.go | 48 ++++++++++++++++++++++++++++++ staticaddr/loopin/interface.go | 4 --- staticaddr/loopin/loopin.go | 10 +------ staticaddr/loopin/manager.go | 30 +++---------------- staticaddr/loopin/manager_test.go | 8 +++-- 10 files changed, 90 insertions(+), 100 deletions(-) create mode 100644 staticaddr/loopin/fsm_test.go diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index 725340cfe..7a34c0fb6 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -669,7 +669,6 @@ func TestMonitorSnapshotIncludesFinalStaticAddressLoopIns(t *testing.T) { func TestStaticLoopInStatusUpdaterUsesSwapHtlcAddress(t *testing.T) { ctx := t.Context() _, staticLoopIn := newGenericStaticLoopInServer(t) - staticLoopIn.AddressParams = nil statusChan := make(chan loop.SwapInfo, 1) updater := &staticLoopInStatusUpdater{ statusChan: statusChan, @@ -916,7 +915,6 @@ func newGenericStaticLoopInServerWithStore(t *testing.T) (*swapClientServer, _, clientPubkey := mock_lnd.CreateKey(10) _, serverPubkey := mock_lnd.CreateKey(11) - addressParams, _ := newTestStaticAddressParams(t) depositOutpoint := wire.OutPoint{ Hash: chainhash.Hash{12, 13, 14}, Index: 2, @@ -940,7 +938,6 @@ func newGenericStaticLoopInServerWithStore(t *testing.T) (*swapClientServer, SelectedAmount: 50_000, DepositOutpoints: []string{depositOutpoint.String()}, Deposits: []*deposit.Deposit{staticDeposit}, - AddressParams: addressParams, } staticLoopIn.SetState(loopin.PaymentReceived) diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index fe9743a31..3e518333c 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -19,7 +19,6 @@ import ( "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/staticutil" - "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/chainntnfs" @@ -177,10 +176,6 @@ func (f *FSM) InitHtlcAction(ctx context.Context, // leave behind a live invoice with no persisted swap to recover it. invoiceNeedsCleanup = true - f.loopIn.ProtocolVersion = version.AddressProtocolVersion( - version.CurrentRPCProtocolVersion(), - ) - depositDescriptors, err := staticutil.DepositAddressDescriptors( f.loopIn.Deposits, ) @@ -192,12 +187,14 @@ func (f *FSM) InitHtlcAction(ctx context.Context, } loopInReq := &swapserverrpc.ServerStaticAddressLoopInRequest{ - SwapHash: f.loopIn.SwapHash[:], - DepositOutpoints: f.loopIn.DepositOutpoints, - Amount: uint64(f.loopIn.SelectedAmount), - HtlcClientPubKey: f.loopIn.ClientPubkey.SerializeCompressed(), - SwapInvoice: f.loopIn.SwapInvoice, - ProtocolVersion: version.CurrentRPCProtocolVersion(), + SwapHash: f.loopIn.SwapHash[:], + DepositOutpoints: f.loopIn.DepositOutpoints, + Amount: uint64(f.loopIn.SelectedAmount), + HtlcClientPubKey: f.loopIn.ClientPubkey.SerializeCompressed(), + SwapInvoice: f.loopIn.SwapInvoice, + ProtocolVersion: swapserverrpc.StaticAddressProtocolVersion( + f.loopIn.ProtocolVersion, + ), UserAgent: loop.UserAgent(f.loopIn.Initiator), PaymentTimeoutSeconds: f.loopIn.PaymentTimeoutSeconds, Fast: f.loopIn.Fast, @@ -610,23 +607,6 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, return f.HandleError(err) } - f.loopIn.AddressParams, err = - f.cfg.AddressManager.GetStaticAddressParameters(ctx) - - if err != nil { - err = fmt.Errorf("unable to get static address parameters: "+ - "%w", err) - - return f.HandleError(err) - } - - f.loopIn.Address, err = f.cfg.AddressManager.GetStaticAddress(ctx) - if err != nil { - err = fmt.Errorf("unable to get static address: %w", err) - - return f.HandleError(err) - } - err = f.checkDepositsAvailable(ctx) if err != nil { return f.HandleError(err) diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index f99d77555..4dfe9d9b9 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sync/atomic" "testing" "time" @@ -919,16 +920,15 @@ func TestSignHtlcTxActionChecksDepositAvailability(t *testing.T) { Value: 200_000, } checker := &recordingTxOutChecker{} + addressMgr := &mockAddressManager{ + getParamsErr: errors.New("legacy address parameters unavailable"), + } f := &FSM{ StateMachine: &fsm.StateMachine{}, cfg: &Config{ - AddressManager: &mockAddressManager{ - params: &script.Parameters{ - ProtocolVersion: version.ProtocolVersion_V0, - }, - }, - TxOutChecker: checker, + AddressManager: addressMgr, + TxOutChecker: checker, }, loopIn: &StaticAddressLoopIn{ Deposits: []*deposit.Deposit{dep}, @@ -942,6 +942,7 @@ func TestSignHtlcTxActionChecksDepositAvailability(t *testing.T) { dep.OutPoint.String()+" is no longer available", ) require.Equal(t, [][]wire.OutPoint{{dep.OutPoint}}, checker.outpoints) + require.Zero(t, addressMgr.getParamsCalls.Load()) } func TestCheckDepositsAvailableRejectsDivergentDepositOutpoints( @@ -3517,21 +3518,21 @@ func TestUnlockDepositsActionReportsTransitionError(t *testing.T) { // mockAddressManager is a minimal AddressManager implementation used by the // test FSM setup. type mockAddressManager struct { - params *script.Parameters + params *script.Parameters + getParamsErr error + getParamsCalls atomic.Int32 } // GetStaticAddressParameters returns the configured address parameters. func (m *mockAddressManager) GetStaticAddressParameters(_ context.Context) ( *script.Parameters, error) { - return m.params, nil -} - -// GetStaticAddress is unused for this test and returns nil. -func (m *mockAddressManager) GetStaticAddress(_ context.Context) ( - *script.StaticAddress, error) { + m.getParamsCalls.Add(1) + if m.getParamsErr != nil { + return nil, m.getParamsErr + } - return nil, nil + return m.params, nil } // NewChangeAddress returns configured parameters for tests that need change. diff --git a/staticaddr/loopin/autoloop_test.go b/staticaddr/loopin/autoloop_test.go index 5d4a78822..a7641f5db 100644 --- a/staticaddr/loopin/autoloop_test.go +++ b/staticaddr/loopin/autoloop_test.go @@ -285,12 +285,13 @@ func TestPrepareAutoloopLoopIn(t *testing.T) { }, } - manager, err := NewManager(&Config{ - AddressManager: &mockAddressManager{ - params: &script.Parameters{ - Expiry: 1_000, - }, + addressMgr := &mockAddressManager{ + params: &script.Parameters{ + Expiry: 1_000, }, + } + manager, err := NewManager(&Config{ + AddressManager: addressMgr, DepositManager: &mockDepositManager{ activeDeposits: []*deposit.Deposit{selectedDeposit}, }, @@ -325,6 +326,7 @@ func TestPrepareAutoloopLoopIn(t *testing.T) { require.Equal(t, "autoloop", quoteGetter.initiator) require.Equal(t, uint32(1), quoteGetter.numDeposits) require.False(t, quoteGetter.fast) + require.EqualValues(t, 1, addressMgr.getParamsCalls.Load()) } // TestPrepareAutoloopLoopInExcludedOutpoints verifies that the manager passes diff --git a/staticaddr/loopin/fsm.go b/staticaddr/loopin/fsm.go index eb53dadaa..642658669 100644 --- a/staticaddr/loopin/fsm.go +++ b/staticaddr/loopin/fsm.go @@ -37,7 +37,7 @@ type FSM struct { } // NewFSM creates a new loop-in state machine. -func NewFSM(ctx context.Context, loopIn *StaticAddressLoopIn, cfg *Config, +func NewFSM(_ context.Context, loopIn *StaticAddressLoopIn, cfg *Config, recoverStateMachine bool) (*FSM, error) { loopInFsm := &FSM{ @@ -45,14 +45,8 @@ func NewFSM(ctx context.Context, loopIn *StaticAddressLoopIn, cfg *Config, loopIn: loopIn, } - params, err := cfg.AddressManager.GetStaticAddressParameters(ctx) - if err != nil { - return nil, fmt.Errorf("unable to get static address "+ - "parameters: %w", err) - } - loopInStates := loopInFsm.LoopInStatesV0() - switch params.ProtocolVersion { + switch loopIn.ProtocolVersion { case version.ProtocolVersion_V0: default: diff --git a/staticaddr/loopin/fsm_test.go b/staticaddr/loopin/fsm_test.go new file mode 100644 index 000000000..3ff1cc67b --- /dev/null +++ b/staticaddr/loopin/fsm_test.go @@ -0,0 +1,48 @@ +package loopin + +import ( + "errors" + "testing" + + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/version" + "github.com/stretchr/testify/require" +) + +// TestNewFSMRecoveryUsesPersistedProtocolVersion verifies that recovering a +// loop-in selects its state machine from the version stored with the swap. It +// must not depend on legacy/root static-address parameters, which might not be +// available for a valid multi-address swap. +func TestNewFSMRecoveryUsesPersistedProtocolVersion(t *testing.T) { + addressMgr := &mockAddressManager{ + getParamsErr: errors.New("legacy address parameters unavailable"), + } + loopIn := &StaticAddressLoopIn{ + ProtocolVersion: version.ProtocolVersion_V0, + } + loopIn.SetState(SignHtlcTx) + + recoveredFSM, err := NewFSM( + t.Context(), loopIn, &Config{AddressManager: addressMgr}, true, + ) + require.NoError(t, err) + require.NotNil(t, recoveredFSM) + require.Zero(t, addressMgr.getParamsCalls.Load()) +} + +// TestNewFSMRejectsPersistedUnsupportedProtocolVersion verifies that the +// persisted swap version, rather than a legacy address row, controls protocol +// validation during FSM construction. +func TestNewFSMRejectsPersistedUnsupportedProtocolVersion(t *testing.T) { + addressMgr := &mockAddressManager{} + loopIn := &StaticAddressLoopIn{ + ProtocolVersion: version.ProtocolVersion_V0 + 1, + } + + loopInFSM, err := NewFSM( + t.Context(), loopIn, &Config{AddressManager: addressMgr}, true, + ) + require.ErrorIs(t, err, deposit.ErrProtocolVersionNotSupported) + require.Nil(t, loopInFSM) + require.Zero(t, addressMgr.getParamsCalls.Load()) +} diff --git a/staticaddr/loopin/interface.go b/staticaddr/loopin/interface.go index aa54e7277..1337995c4 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -39,10 +39,6 @@ type AddressManager interface { GetStaticAddressParameters(ctx context.Context) (*script.Parameters, error) - // 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) diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index cc811ad3f..0203c3580 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -19,7 +19,6 @@ import ( "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" "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightninglabs/loop/swap" @@ -70,7 +69,7 @@ type StaticAddressLoopIn struct { // InitiationTime is the time at which the swap was initiated. InitiationTime time.Time - // ProtocolVersion is the protocol version of the static address. + // ProtocolVersion is the protocol version selected for this loop-in. ProtocolVersion version.AddressProtocolVersion // Label contains an optional label for the swap. @@ -159,13 +158,6 @@ type StaticAddressLoopIn struct { // implicitly carry the swap amount. Deposits []*deposit.Deposit - // AddressParams are the parameters of the address that is used for the - // swap. - AddressParams *script.Parameters - - // 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. diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index f9759042e..a45d27c24 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -22,6 +22,7 @@ import ( "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/staticutil" + "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" @@ -283,18 +284,6 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, return err } - loopIn.AddressParams, err = - m.cfg.AddressManager.GetStaticAddressParameters(ctx) - - if err != nil { - return err - } - - loopIn.Address, err = m.cfg.AddressManager.GetStaticAddress(ctx) - if err != nil { - return err - } - ignoreUnknownOutpoints := false deposits, err := m.cfg.DepositManager.DepositsForOutpoints( ctx, loopIn.DepositOutpoints, ignoreUnknownOutpoints, @@ -584,20 +573,6 @@ func (m *Manager) recoverLoopIns(ctx context.Context) error { loopIn.Deposits = activeDeposits } - loopIn.AddressParams, err = - m.cfg.AddressManager.GetStaticAddressParameters(ctx) - - if err != nil { - return err - } - - loopIn.Address, err = m.cfg.AddressManager.GetStaticAddress( - ctx, - ) - if err != nil { - return err - } - // Create a state machine for a given loop-in. recovery := true fsm, err := NewFSM(ctx, loopIn, m.cfg, recovery) @@ -786,6 +761,9 @@ func (m *Manager) initiateLoopIn(ctx context.Context, } swap := &StaticAddressLoopIn{ + ProtocolVersion: version.AddressProtocolVersion( + version.CurrentRPCProtocolVersion(), + ), SelectedAmount: req.SelectedAmount, // Copy into a nil slice so the swap owns a stable snapshot // instead of aliasing the caller's selectedOutpoints slice. diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index af036762b..85a7acff5 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -373,11 +373,12 @@ func TestHandleLoopInSweepReqRejectsInvalidServerNonce(t *testing.T) { var psbtBuf bytes.Buffer require.NoError(t, sweepPacket.Serialize(&psbtBuf)) + addressMgr := &mockAddressManager{ + getParamsErr: errors.New("legacy address parameters unavailable"), + } mgr := &Manager{ cfg: &Config{ - AddressManager: &mockAddressManager{ - params: changeAddr, - }, + AddressManager: addressMgr, DepositManager: &mockDepositManager{ byOutpoint: map[string]*deposit.Deposit{ depOutpoint: dep, @@ -411,6 +412,7 @@ func TestHandleLoopInSweepReqRejectsInvalidServerNonce(t *testing.T) { err = mgr.handleLoopInSweepReq(ctx, req) require.ErrorContains(t, err, "invalid server nonce") require.ErrorContains(t, err, depOutpoint) + require.Zero(t, addressMgr.getParamsCalls.Load()) } // TestActiveDepositsForLoopInUsesCurrentDepositOutpoints verifies that From 06ad25f98fc5519221de9df1f62f67576d0b37b7 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 27 Aug 2026 10:47:10 +0200 Subject: [PATCH 13/30] staticaddr/withdraw: use generated change addresses Create a fresh static address for partial-withdrawal change and persist that output from the confirmed transaction. Keep all withdrawal outputs in the PSBT without separate signing metadata while preserving full-withdrawal behavior. --- staticaddr/withdraw/generated_change_test.go | 219 +++++++++++++++++++ staticaddr/withdraw/interface.go | 5 + staticaddr/withdraw/manager.go | 97 ++++---- staticaddr/withdraw/manager_test.go | 84 +++++++ 4 files changed, 362 insertions(+), 43 deletions(-) create mode 100644 staticaddr/withdraw/generated_change_test.go diff --git a/staticaddr/withdraw/generated_change_test.go b/staticaddr/withdraw/generated_change_test.go new file mode 100644 index 000000000..cd49b7bfd --- /dev/null +++ b/staticaddr/withdraw/generated_change_test.go @@ -0,0 +1,219 @@ +package withdraw + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +type generatedChangeTestAddressManager struct { + params *address.Parameters + err error + calls int +} + +func (m *generatedChangeTestAddressManager) GetStaticAddressParameters( + context.Context) (*script.Parameters, error) { + + return nil, nil +} + +func (m *generatedChangeTestAddressManager) GetStaticAddress( + context.Context) (*script.StaticAddress, error) { + + return nil, nil +} + +func (m *generatedChangeTestAddressManager) NewChangeAddress( + context.Context) (*address.Parameters, error) { + + m.calls++ + + return m.params, m.err +} + +// GetParameters satisfies the address manager interface used by the +// withdrawal replacement monitor later in the multi-address stack. +func (m *generatedChangeTestAddressManager) GetParameters( + pkScript []byte) *address.Parameters { + + if m.params == nil || !bytes.Equal(m.params.PkScript, pkScript) { + return nil + } + + return m.params +} + +type generatedChangeTestServer struct { + swapserverrpc.StaticAddressServerClient + + request *swapserverrpc.ServerPsbtWithdrawRequest + err error + calls int +} + +func (s *generatedChangeTestServer) ServerPsbtWithdrawDeposits( + _ context.Context, request *swapserverrpc.ServerPsbtWithdrawRequest, + _ ...grpc.CallOption) (*swapserverrpc.ServerPsbtWithdrawResponse, error) { + + s.calls++ + s.request = request + + return nil, s.err +} + +// TestCreateFinalizedWithdrawalTxUsesGeneratedChange verifies that only a +// non-dust partial withdrawal derives a fresh static address and that the +// exact generated script is included in the PSBT sent to the server. +func TestCreateFinalizedWithdrawalTxUsesGeneratedChange(t *testing.T) { + t.Parallel() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + depositPkScript := testTaprootPkScript(1) + changePkScript := testTaprootPkScript(2) + changeParams := &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PkScript: changePkScript, + } + deposits := []*deposit.Deposit{{ + OutPoint: wire.OutPoint{Index: 1}, + Value: 100_000, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + Expiry: 144, + PkScript: depositPkScript, + }, + }} + + withdrawalAddress, err := btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + withdrawalPkScript, err := txscript.PayToAddrScript(withdrawalAddress) + require.NoError(t, err) + + serverErr := errors.New("stop after recording withdrawal request") + addressErr := errors.New("change address unavailable") + tests := []struct { + name string + selectedAmount int64 + addressErr error + wantAddressCalls int + wantServerCalls int + wantOutputs []*wire.TxOut + }{ + { + name: "partial non-dust change", + selectedAmount: 50_000, + wantAddressCalls: 1, + wantServerCalls: 1, + wantOutputs: []*wire.TxOut{ + { + Value: 50_000, + PkScript: withdrawalPkScript, + }, + { + Value: 50_000, + PkScript: changePkScript, + }, + }, + }, + { + name: "full withdrawal", + wantServerCalls: 1, + wantOutputs: []*wire.TxOut{{ + Value: 100_000, + PkScript: withdrawalPkScript, + }}, + }, + { + name: "dust change", + selectedAmount: 99_900, + wantServerCalls: 1, + wantAddressCalls: 0, + wantOutputs: []*wire.TxOut{{ + Value: 99_900, + PkScript: withdrawalPkScript, + }}, + }, + { + name: "address generation failure", + selectedAmount: 50_000, + addressErr: addressErr, + wantAddressCalls: 1, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + addressManager := &generatedChangeTestAddressManager{ + params: changeParams, + err: testCase.addressErr, + } + server := &generatedChangeTestServer{err: serverErr} + manager := &Manager{cfg: &ManagerConfig{ + StaticAddressServerClient: server, + AddressManager: addressManager, + Signer: &withdrawalCleanupSigner{}, + }} + + _, _, err := manager.CreateFinalizedWithdrawalTx( + t.Context(), deposits, withdrawalAddress, 0, + testCase.selectedAmount, + lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + ) + if testCase.addressErr != nil { + require.ErrorIs(t, err, testCase.addressErr) + } else { + require.ErrorIs(t, err, serverErr) + } + + require.Equal( + t, testCase.wantAddressCalls, addressManager.calls, + ) + require.Equal(t, testCase.wantServerCalls, server.calls) + if testCase.wantServerCalls == 0 { + require.Nil(t, server.request) + return + } + + require.NotNil(t, server.request) + packet, err := psbt.NewFromRawBytes( + bytes.NewReader(server.request.WithdrawalPsbt), false, + ) + require.NoError(t, err) + require.Equal( + t, testCase.wantOutputs, packet.UnsignedTx.TxOut, + ) + }) + } +} + +func testTaprootPkScript(value byte) []byte { + return append( + []byte{txscript.OP_1, 32}, bytes.Repeat([]byte{value}, 32)..., + ) +} diff --git a/staticaddr/withdraw/interface.go b/staticaddr/withdraw/interface.go index 0f32697a9..b2698fc44 100644 --- a/staticaddr/withdraw/interface.go +++ b/staticaddr/withdraw/interface.go @@ -5,6 +5,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" ) @@ -18,6 +19,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) } type DepositManager interface { diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 3ee95ce09..106ce1bfa 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -9,7 +9,6 @@ import ( "sync" "sync/atomic" - "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" @@ -19,6 +18,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/staticutil" staticaddressrpc "github.com/lightninglabs/loop/swapserverrpc" @@ -280,8 +280,7 @@ func (m *Manager) recoverWithdrawals(ctx context.Context) error { } err = m.handleWithdrawal( - ctx, deposits, tx.TxHash(), - tx.TxOut[0].PkScript, + ctx, deposits, tx.TxHash(), tx.TxOut[0].PkScript, ) if err != nil { return err @@ -567,10 +566,28 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, "input proofs: %w", err) } + _, changeAmount, err := CalculateWithdrawalTxValues( + deposits, btcutil.Amount(selectedWithdrawalAmount), feeRate, + withdrawalAddress, commitmentType, + ) + if err != nil { + return nil, nil, fmt.Errorf("error calculating funding tx "+ + "values: %w", err) + } + + var changeParams *address.Parameters + if changeAmount > 0 { + changeParams, err = m.cfg.AddressManager.NewChangeAddress(ctx) + if err != nil { + return nil, nil, fmt.Errorf("unable to create static "+ + "address change output: %w", err) + } + } + withdrawalTx, unsignedPsbt, err := m.createWithdrawalTx( - ctx, outpoints, deposits, prevOuts, + outpoints, deposits, prevOuts, btcutil.Amount(selectedWithdrawalAmount), withdrawalAddress, - feeRate, commitmentType, + feeRate, commitmentType, changeParams, ) if err != nil { return nil, nil, err @@ -578,10 +595,10 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, // Request the server to sign the withdrawal transaction. // - // The withdrawal and change amount are sent to the server with the - // expectation that the server just signs the transaction, without - // performing fee calculations and dust considerations. The client is - // responsible for that. + // All withdrawal outputs, including any change output, are encoded in + // the PSBT. The server signs the transaction as constructed without + // performing fee calculations or dust handling. The client is + // responsible for both. // nolint:lll sigResp, err := m.cfg.StaticAddressServerClient.ServerPsbtWithdrawDeposits( ctx, &staticaddressrpc.ServerPsbtWithdrawRequest{ @@ -666,22 +683,29 @@ func (m *Manager) publishFinalizedWithdrawalTx(ctx context.Context, return true, nil } +func withdrawalChangePkScript(tx *wire.MsgTx) []byte { + if tx == nil || len(tx.TxOut) < 2 { + return nil + } + + return tx.TxOut[1].PkScript +} + // handleWithdrawal starts a goroutine that listens for the spent of the first // input of the withdrawal transaction. func (m *Manager) handleWithdrawal(ctx context.Context, - deposits []*deposit.Deposit, txHash chainhash.Hash, - withdrawalPkscript []byte) error { + deposits []*deposit.Deposit, originalTxHash chainhash.Hash, + withdrawalPkScript []byte) error { - addrParams, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) - if err != nil { - log.Errorf("error retrieving address params: %v", err) - - return fmt.Errorf("withdrawal failed") + d := deposits[0] + if d.AddressParams == nil { + return fmt.Errorf("missing static address parameters for %v", + d.OutPoint) } + depositPkScript := d.AddressParams.PkScript - d := deposits[0] spentChan, errChan, err := m.cfg.ChainNotifier.RegisterSpendNtfn( - ctx, &d.OutPoint, addrParams.PkScript, + ctx, &d.OutPoint, depositPkScript, int32(d.GetConfirmationHeight()), ) if err != nil { @@ -692,13 +716,15 @@ func (m *Manager) handleWithdrawal(ctx context.Context, select { case spentTx := <-spentChan: spendingHeight := uint32(spentTx.SpendingHeight) + // If the transaction received one confirmation, we // ensure re-org safety by waiting for some more // confirmations. confChan, confErrChan, err := m.cfg.ChainNotifier.RegisterConfirmationsNtfn( ctx, spentTx.SpenderTxHash, - withdrawalPkscript, MinConfs, + withdrawalPkScript, + MinConfs, int32(m.initiationHeight.Load()), ) if err != nil { @@ -725,13 +751,13 @@ func (m *Manager) handleWithdrawal(ctx context.Context, // withdrawals to stop republishing it on block // arrivals. m.mu.Lock() - delete(m.finalizedWithdrawalTxns, txHash) + delete(m.finalizedWithdrawalTxns, originalTxHash) m.mu.Unlock() // Persist info about the finalized withdrawal. err = m.cfg.Store.UpdateWithdrawal( ctx, deposits, tx.Tx, spendingHeight, - addrParams.PkScript, + withdrawalChangePkScript(tx.Tx), ) if err != nil { log.Errorf("Error persisting "+ @@ -888,12 +914,13 @@ func (m *Manager) signMusig2Tx(ctx context.Context, return tx, nil } -func (m *Manager) createWithdrawalTx(ctx context.Context, +func (m *Manager) createWithdrawalTx( outpoints []wire.OutPoint, deposits []*deposit.Deposit, prevOuts map[wire.OutPoint]*wire.TxOut, selectedWithdrawalAmount btcutil.Amount, withdrawAddr btcutil.Address, feeRate chainfee.SatPerKWeight, - commitmentType lnrpc.CommitmentType) (*wire.MsgTx, []byte, error) { + commitmentType lnrpc.CommitmentType, + changeParams *address.Parameters) (*wire.MsgTx, []byte, error) { // First Create the tx. msgTx := wire.NewMsgTx(2) @@ -940,30 +967,14 @@ func (m *Manager) createWithdrawalTx(ctx context.Context, }) if changeAmount > 0 { - // Send change back to the same static address. - staticAddress, err := m.cfg.AddressManager.GetStaticAddress(ctx) - if err != nil { - log.Errorf("error retrieving taproot address %v", err) - - return nil, nil, fmt.Errorf("withdrawal failed") - } - - changeAddress, err := btcutil.NewAddressTaproot( - schnorr.SerializePubKey(staticAddress.TaprootKey), - m.cfg.ChainParams, - ) - if err != nil { - return nil, nil, err - } - - changeScript, err := txscript.PayToAddrScript(changeAddress) - if err != nil { - return nil, nil, err + if changeParams == nil { + return nil, nil, fmt.Errorf("missing static address " + + "change parameters") } msgTx.AddTxOut(&wire.TxOut{ Value: int64(changeAmount), - PkScript: changeScript, + PkScript: changeParams.PkScript, }) } diff --git a/staticaddr/withdraw/manager_test.go b/staticaddr/withdraw/manager_test.go index 6c883a7cc..23f243af2 100644 --- a/staticaddr/withdraw/manager_test.go +++ b/staticaddr/withdraw/manager_test.go @@ -4,23 +4,50 @@ import ( "context" "testing" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "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/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/funding" "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/require" ) +type withdrawalCleanupSigner struct { + lndclient.SignerClient + + cleaned [][32]byte + cleanupCtxErr []error +} + +func (s *withdrawalCleanupSigner) MuSig2CreateSession(context.Context, + input.MuSig2Version, *keychain.KeyLocator, [][]byte, + ...lndclient.MuSig2SessionOpts) (*input.MuSig2SessionInfo, error) { + + return &input.MuSig2SessionInfo{SessionID: [32]byte{1}}, nil +} + +func (s *withdrawalCleanupSigner) MuSig2Cleanup(ctx context.Context, + sessionID [32]byte) error { + + s.cleaned = append(s.cleaned, sessionID) + s.cleanupCtxErr = append(s.cleanupCtxErr, ctx.Err()) + + return nil +} + // TestNewManagerHeightValidation ensures the constructor rejects zero heights. func TestNewManagerHeightValidation(t *testing.T) { t.Parallel() @@ -35,6 +62,63 @@ func TestNewManagerHeightValidation(t *testing.T) { require.NotNil(t, manager) } +func TestWithdrawalChangePkScript(t *testing.T) { + t.Parallel() + + require.Nil(t, withdrawalChangePkScript(nil)) + + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: []byte{0x01}, + }) + require.Nil(t, withdrawalChangePkScript(tx)) + + tx.AddTxOut(&wire.TxOut{ + Value: 500, + PkScript: []byte{0x02}, + }) + require.Equal(t, []byte{0x02}, withdrawalChangePkScript(tx)) +} + +func TestCreateFinalizedWithdrawalTxCleansUpSessionsOnError(t *testing.T) { + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + signer := &withdrawalCleanupSigner{} + manager := &Manager{cfg: &ManagerConfig{Signer: signer}} + deposits := []*deposit.Deposit{ + { + OutPoint: wire.OutPoint{Index: 1}, + Value: 100_000, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + Expiry: 144, + PkScript: []byte{0x51}, + KeyLocator: keychain.KeyLocator{ + Family: 1, + Index: 2, + }, + }, + }, + } + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, _, err = manager.CreateFinalizedWithdrawalTx( + ctx, deposits, nil, 1_000, 0, + lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + ) + require.ErrorContains( + t, err, "either address or commitment type must be specified", + ) + require.Equal(t, [][32]byte{{1}}, signer.cleaned) + require.Equal(t, []error{nil}, signer.cleanupCtxErr) +} + // TestSignMusig2Tx_MissingSigningInfo tests that signMusig2Tx should error // when sigInfo is missing an entry for one of the deposits. // From 396e86ff9a5274700ce29ee7d89eefa1e341f122 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 27 Aug 2026 10:47:26 +0200 Subject: [PATCH 14/30] staticaddr: fund new addresses with sendcoins Let loop static deposit create and fund a fresh receive address through lnd SendCoins. Validate funding arguments before address creation and require explicit confirmation unless --force is set, including for non-interactive and first-use deposits. Resolve requested existing addresses through the active script index rather than reconstructing every persisted address. Expose the nested request through the client RPC, require swap:execute permission, and cover the CLI and daemon funding paths. Regenerate RPC and CLI documentation. --- cmd/loop/staticaddr.go | 352 +++++++++++++++++- cmd/loop/staticaddr_test.go | 183 +++++++++ .../static-loop-in/01_loop-static-new.json | 6 +- .../static-loop-in/04_loop-static.json | 1 + docs/loop.1 | 33 ++ docs/loop.md | 29 +- go.mod | 2 +- loopd/swapclient_server.go | 151 +++++++- loopd/swapclient_server_staticaddr_test.go | 256 ++++++++++++- looprpc/client.pb.go | 219 ++++++----- looprpc/client.proto | 13 + looprpc/client.swagger.json | 85 +++++ looprpc/perms.go | 2 +- 13 files changed, 1211 insertions(+), 121 deletions(-) diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index c973a0028..aef19f411 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -4,11 +4,15 @@ import ( "context" "errors" "fmt" + "io" + "math" + "os" "sort" "strings" "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/loopin" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd" @@ -17,6 +21,7 @@ import ( "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/routing/route" "github.com/urfave/cli/v3" + "golang.org/x/term" ) func init() { @@ -29,6 +34,7 @@ var staticAddressCommands = &cli.Command{ Usage: "perform on-chain to off-chain swaps using static addresses.", Commands: []*cli.Command{ newStaticAddressCommand, + depositStaticAddressCommand, listUnspentCommand, listDepositsCommand, listWithdrawalsCommand, @@ -45,15 +51,100 @@ var newStaticAddressCommand = &cli.Command{ Aliases: []string{"n"}, Usage: "Create a new static loop in address.", Description: ` - Requests a new static loop in address from the server. Funds that are - sent to this address will be locked by a 2:2 multisig between us and the - loop server, or a timeout path that we can sweep once it opens up. The - funds can either be cooperatively spent with a signature from the server - or looped in. + Creates a new static loop in address. On a fresh installation loopd + initializes the static-address generation during startup. Funds sent to the + address will be locked by a 2:2 multisig between us and the loop server, or + a timeout path that we can sweep once it opens up. The funds can either be + cooperatively spent with a signature from the server or looped in. `, Action: newStaticAddress, } +var depositStaticAddressCommand = &cli.Command{ + Name: "deposit", + Usage: "Create and fund a new static loop in address.", + Description: ` + Creates a new static loop in address and initiates a deposit by calling + lnd's SendCoins API with the newly created address as the destination. + `, + Flags: []cli.Flag{ + &cli.Int64Flag{ + Name: "amt", + Usage: "the number of bitcoin denominated in satoshis " + + "to send to the new static address", + }, + &cli.BoolFlag{ + Name: "sweepall", + Usage: "if set, then the amount field should be " + + "unset. This indicates that the wallet will " + + "attempt to sweep all outputs within the " + + "wallet or all funds in selected utxos (when " + + "supplied) to the new static address", + }, + &cli.Int64Flag{ + Name: "conf_target", + Usage: "(optional) the number of blocks that the " + + "funding transaction should confirm in, will " + + "be used for fee estimation", + }, + &cli.Int64Flag{ + Name: "sat_per_byte", + Usage: "Deprecated, use sat_per_vbyte instead.", + Hidden: true, + }, + &cli.Uint64Flag{ + Name: "sat_per_vbyte", + Usage: "(optional) a manual fee expressed in " + + "sat/vbyte that should be used when crafting " + + "the funding transaction", + }, + &cli.Uint64Flag{ + Name: "min_confs", + Usage: "(optional) the minimum number of confirmations " + + "each one of your outputs used for the funding " + + "transaction must satisfy", + Value: defaultUtxoMinConf, + }, + &cli.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "if set, the funding transaction will be " + + "broadcast without asking for confirmation", + }, + staticAddressCoinSelectionStrategyFlag, + &cli.StringSliceFlag{ + Name: "utxo", + Usage: "a utxo specified as outpoint(tx:idx) which " + + "will be used as input for the funding " + + "transaction. This flag can be repeatedly used " + + "to specify multiple utxos as inputs. The " + + "selected utxos can either be entirely spent " + + "by specifying the sweepall flag or a specified " + + "amount can be spent in the utxos through " + + "the amt flag", + }, + staticAddressFundingLabelFlag, + }, + Action: depositStaticAddress, +} + +var ( + staticAddressCoinSelectionStrategyFlag = &cli.StringFlag{ + Name: "coin_selection_strategy", + Usage: "(optional) the strategy to use for selecting coins. " + + "Possible values are 'largest', 'random', or " + + "'global-config'. If either 'largest' or 'random' is " + + "specified, it will override the globally configured " + + "strategy in lnd.conf", + Value: "global-config", + } + + staticAddressFundingLabelFlag = &cli.StringFlag{ + Name: "label", + Usage: "(optional) a label for the funding transaction", + } +) + func newStaticAddress(ctx context.Context, cmd *cli.Command) error { if cmd.NArg() > 0 { return showCommandHelp(ctx, cmd) @@ -82,6 +173,210 @@ func newStaticAddress(ctx context.Context, cmd *cli.Command) error { return nil } +func depositStaticAddress(ctx context.Context, cmd *cli.Command) error { + if cmd.NArg() > 0 { + return showCommandHelp(ctx, cmd) + } + + client, cleanup, err := getClient(cmd) + if err != nil { + return err + } + defer cleanup() + + resp, err := executeStaticAddressDeposit( + ctx, cmd, client, os.Stdin, os.Stderr, + term.IsTerminal(int(os.Stdin.Fd())), + ) + if err != nil { + return err + } + if resp != nil { + printRespJSON(resp) + } + + return nil +} + +func executeStaticAddressDeposit(ctx context.Context, cmd *cli.Command, + client looprpc.SwapClientClient, input io.Reader, output io.Writer, + interactive bool) (*looprpc.NewStaticAddressResponse, error) { + + req, err := staticAddressDepositRequest(cmd, "") + if err != nil { + return nil, err + } + + force := cmd.Bool("force") + if !force && !interactive { + return nil, errors.New("deposit confirmation requires an " + + "interactive terminal; use --force to broadcast " + + "non-interactively") + } + + err = maybeDisplayNewAddressWarning( + ctx, client, force, input, output, + ) + if err != nil { + return nil, err + } + + if !force { + confirmed, err := confirmStaticAddressDeposit( + req, input, output, + ) + if err != nil { + return nil, err + } + if !confirmed { + return nil, nil + } + } + + resp, err := client.NewStaticAddress(ctx, req) + if err != nil { + return nil, err + } + + return resp, nil +} + +func staticAddressDepositRequest( + cmd *cli.Command, addr string) (*looprpc.NewStaticAddressRequest, error) { + + if !cmd.IsSet("amt") && !cmd.Bool("sweepall") { + return nil, errors.New("amount argument missing") + } + + amount := cmd.Int64("amt") + if cmd.IsSet("amt") && amount <= 0 { + return nil, errors.New("amount must be positive") + } + + if amount != 0 && cmd.Bool("sweepall") { + return nil, errors.New("amount cannot be set if " + + "attempting to sweep all coins out of the wallet") + } + + feeRateFlag, err := checkNotBothSet( + cmd, "sat_per_vbyte", "sat_per_byte", + ) + if err != nil { + return nil, err + } + + if _, err := checkNotBothSet( + cmd, feeRateFlag, "conf_target", + ); err != nil { + return nil, err + } + + var satPerByte int64 + if cmd.IsSet("sat_per_byte") { + satPerByte = cmd.Int64("sat_per_byte") + if satPerByte < 0 { + return nil, fmt.Errorf("sat_per_byte must be " + + "non-negative") + } + } + + confTarget := cmd.Int64("conf_target") + if confTarget < 0 { + return nil, fmt.Errorf("conf_target must be non-negative") + } + if confTarget > math.MaxInt32 { + return nil, fmt.Errorf("conf_target exceeds maximum " + + "int32 value") + } + + minConfs := cmd.Uint64("min_confs") + if minConfs > math.MaxInt32 { + return nil, fmt.Errorf("min_confs exceeds maximum " + + "int32 value") + } + + var outpoints []*lnrpc.OutPoint + utxos := cmd.StringSlice("utxo") + if len(utxos) > 0 { + outpoints, err = lnd.UtxosToOutpoints(utxos) + if err != nil { + return nil, fmt.Errorf("unable to decode utxos: %w", err) + } + } + + coinSelectionStrategy, err := parseStaticAddressCoinSelectionStrategy(cmd) + if err != nil { + return nil, err + } + + return &looprpc.NewStaticAddressRequest{ + SendCoinsRequest: &lnrpc.SendCoinsRequest{ + Addr: addr, + Amount: amount, + TargetConf: int32(confTarget), + SatPerVbyte: cmd.Uint64("sat_per_vbyte"), + SatPerByte: satPerByte, + SendAll: cmd.Bool("sweepall"), + Label: cmd.String( + staticAddressFundingLabelFlag.Name, + ), + MinConfs: int32(minConfs), + SpendUnconfirmed: minConfs == 0, + CoinSelectionStrategy: coinSelectionStrategy, + Outpoints: outpoints, + }, + }, nil +} + +func parseStaticAddressCoinSelectionStrategy(cmd *cli.Command) ( + lnrpc.CoinSelectionStrategy, error) { + + if !cmd.IsSet(staticAddressCoinSelectionStrategyFlag.Name) { + return lnrpc.CoinSelectionStrategy_STRATEGY_USE_GLOBAL_CONFIG, + nil + } + + switch strategy := cmd.String( + staticAddressCoinSelectionStrategyFlag.Name); strategy { + case "global-config": + return lnrpc.CoinSelectionStrategy_STRATEGY_USE_GLOBAL_CONFIG, + nil + + case "largest": + return lnrpc.CoinSelectionStrategy_STRATEGY_LARGEST, nil + + case "random": + return lnrpc.CoinSelectionStrategy_STRATEGY_RANDOM, nil + + default: + return 0, fmt.Errorf("unknown coin selection strategy %v", + strategy) + } +} + +func confirmStaticAddressDeposit(req *looprpc.NewStaticAddressRequest, + input io.Reader, output io.Writer) (bool, error) { + + sendCoinsReq := req.GetSendCoinsRequest() + if sendCoinsReq.GetSendAll() { + fmt.Fprintln(output, "Amount: sweep all eligible wallet funds") + } else { + fmt.Fprintf(output, "Amount: %d\n", sendCoinsReq.GetAmount()) + } + + fmt.Fprintln(output, "Destination: a newly derived static address") + fmt.Fprint(output, "Confirm funding transaction (yes/no): ") + + var answer string + _, err := fmt.Fscan(input, &answer) + if err != nil { + return false, fmt.Errorf("unable to read deposit confirmation: %w", + err) + } + + return answer == "yes" || answer == "y", nil +} + var listUnspentCommand = &cli.Command{ Name: "listunspent", Aliases: []string{"l"}, @@ -846,19 +1141,50 @@ func lowConfDepositWarning(allDeposits []*looprpc.Deposit, ) } +func maybeDisplayNewAddressWarning(ctx context.Context, + client looprpc.SwapClientClient, force bool, input io.Reader, + output io.Writer) error { + + _, err := client.GetStaticAddressSummary( + ctx, &looprpc.StaticAddressSummaryRequest{}, + ) + switch { + case err == nil: + return nil + + case strings.Contains(err.Error(), address.ErrNoStaticAddress.Error()): + return displayNewAddressWarningTo(input, output, force) + + default: + return err + } +} + func displayNewAddressWarning() error { - fmt.Printf("\nWARNING: Be aware that loosing your l402.token file in " + - ".loop under your home directory will take your ability to " + - "spend funds sent to the static address via loop-ins or " + - "withdrawals. You will have to wait until the deposit " + - "expires and your loop client sweeps the funds back to your " + - "lnd wallet. The deposit expiry could be months in the " + + return displayNewAddressWarningTo(os.Stdin, os.Stdout, false) +} + +func displayNewAddressWarningTo(input io.Reader, output io.Writer, + force bool) error { + + fmt.Fprint(output, "\nWARNING: Be aware that loosing your l402.token file in "+ + ".loop under your home directory will take your ability to "+ + "spend funds sent to the static address via loop-ins or "+ + "withdrawals. You will have to wait until the deposit "+ + "expires and your loop client sweeps the funds back to your "+ + "lnd wallet. The deposit expiry could be months in the "+ "future.\n") + if force { + return nil + } - fmt.Printf("\nCONTINUE WITH NEW ADDRESS? (y/n): ") + fmt.Fprint(output, "\nCONTINUE WITH NEW ADDRESS? (y/n): ") var answer string - fmt.Scanln(&answer) + _, err := fmt.Fscanln(input, &answer) + if err != nil { + return fmt.Errorf("read new address confirmation: %w", err) + } if answer == "y" { return nil } diff --git a/cmd/loop/staticaddr_test.go b/cmd/loop/staticaddr_test.go index 2f7bcfb84..e99d580d9 100644 --- a/cmd/loop/staticaddr_test.go +++ b/cmd/loop/staticaddr_test.go @@ -1,6 +1,9 @@ package main import ( + "bytes" + "context" + "errors" "strings" "testing" @@ -11,9 +14,189 @@ import ( "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "google.golang.org/grpc" ) +type staticAddressSummaryErrorClient struct { + looprpc.SwapClientClient + + err error + newAddressRequest *looprpc.NewStaticAddressRequest + newAddressResponse *looprpc.NewStaticAddressResponse + newAddressCalls int +} + +func (c *staticAddressSummaryErrorClient) GetStaticAddressSummary( + context.Context, *looprpc.StaticAddressSummaryRequest, + ...grpc.CallOption) (*looprpc.StaticAddressSummaryResponse, error) { + + return nil, c.err +} + +func (c *staticAddressSummaryErrorClient) NewStaticAddress( + _ context.Context, req *looprpc.NewStaticAddressRequest, + _ ...grpc.CallOption) (*looprpc.NewStaticAddressResponse, error) { + + c.newAddressCalls++ + c.newAddressRequest = req + + return c.newAddressResponse, nil +} + +func TestMaybeDisplayNewAddressWarningReturnsUnexpectedError(t *testing.T) { + t.Parallel() + + expectedErr := errors.New("permission denied") + err := maybeDisplayNewAddressWarning( + context.Background(), &staticAddressSummaryErrorClient{ + err: expectedErr, + }, + false, strings.NewReader(""), &bytes.Buffer{}, + ) + require.ErrorIs(t, err, expectedErr) +} + +func TestStaticAddressDepositRequiresInteractiveConfirmation(t *testing.T) { + client := &staticAddressSummaryErrorClient{} + cmd := &cli.Command{ + Name: "deposit", + Flags: depositStaticAddressCommand.Flags, + Action: func(ctx context.Context, cmd *cli.Command) error { + _, err := executeStaticAddressDeposit( + ctx, cmd, client, strings.NewReader(""), + &bytes.Buffer{}, false, + ) + + return err + }, + } + + err := cmd.Run(t.Context(), []string{ + "deposit", "--amt", "100000", + }) + require.ErrorContains(t, err, "requires an interactive terminal") + require.Zero(t, client.newAddressCalls) +} + +func TestStaticAddressDepositForceFirstUseNonInteractive(t *testing.T) { + client := &staticAddressSummaryErrorClient{ + err: address.ErrNoStaticAddress, + newAddressResponse: &looprpc.NewStaticAddressResponse{ + Address: "bcrt1ptestaddress", + }, + } + + var ( + resp *looprpc.NewStaticAddressResponse + output bytes.Buffer + ) + cmd := &cli.Command{ + Name: "deposit", + Flags: depositStaticAddressCommand.Flags, + Action: func(ctx context.Context, cmd *cli.Command) error { + var err error + resp, err = executeStaticAddressDeposit( + ctx, cmd, client, strings.NewReader(""), &output, + false, + ) + + return err + }, + } + + err := cmd.Run(t.Context(), []string{ + "deposit", "--amt", "100000", "--force", + }) + require.NoError(t, err) + require.Same(t, client.newAddressResponse, resp) + require.Equal(t, 1, client.newAddressCalls) + require.EqualValues( + t, 100_000, client.newAddressRequest.GetSendCoinsRequest().Amount, + ) + require.Empty(t, client.newAddressRequest.GetSendCoinsRequest().Addr) + require.Contains(t, output.String(), "WARNING") + require.NotContains(t, output.String(), "CONTINUE WITH NEW ADDRESS") +} + +func TestStaticAddressDepositRequestAllowsNoUtxos(t *testing.T) { + t.Parallel() + + var req *looprpc.NewStaticAddressRequest + cmd := &cli.Command{ + Name: "deposit", + Flags: depositStaticAddressCommand.Flags, + Action: func(_ context.Context, cmd *cli.Command) error { + var err error + req, err = staticAddressDepositRequest( + cmd, "bcrt1ptestaddress", + ) + + return err + }, + } + + err := cmd.Run(context.Background(), []string{ + "deposit", "--amt", "1000000", + }) + require.NoError(t, err) + require.Equal(t, "bcrt1ptestaddress", req.GetSendCoinsRequest().Addr) + require.EqualValues(t, 1_000_000, req.GetSendCoinsRequest().Amount) + require.Empty(t, req.GetSendCoinsRequest().Outpoints) +} + +func TestStaticAddressDepositForceAlias(t *testing.T) { + var forceFlag *cli.BoolFlag + for _, flag := range depositStaticAddressCommand.Flags { + boolFlag, ok := flag.(*cli.BoolFlag) + if ok && boolFlag.Name == "force" { + forceFlag = boolFlag + break + } + } + require.NotNil(t, forceFlag) + + for _, flag := range []string{"--force", "-f"} { + t.Run(flag, func(t *testing.T) { + flagCopy := *forceFlag + var forced bool + cmd := &cli.Command{ + Name: "deposit", + Flags: []cli.Flag{&flagCopy}, + Action: func(_ context.Context, + cmd *cli.Command) error { + + forced = cmd.Bool("force") + return nil + }, + } + + err := cmd.Run(t.Context(), []string{"deposit", flag}) + require.NoError(t, err) + require.True(t, forced) + }) + } +} + +func TestConfirmStaticAddressDeposit(t *testing.T) { + t.Parallel() + + req := &looprpc.NewStaticAddressRequest{ + SendCoinsRequest: &lnrpc.SendCoinsRequest{Amount: 10_000}, + } + + var output bytes.Buffer + confirmed, err := confirmStaticAddressDeposit( + req, strings.NewReader("yes\n"), &output, + ) + require.NoError(t, err) + require.True(t, confirmed) + require.Contains(t, output.String(), "Amount: 10000") + require.Contains(t, output.String(), "newly derived static address") +} + // TestLowConfDepositWarningConfirmedOnly verifies confirmed deposits below the // conservative warning threshold are included in the warning text. func TestLowConfDepositWarningConfirmedOnly(t *testing.T) { diff --git a/cmd/loop/testdata/sessions/static-loop-in/01_loop-static-new.json b/cmd/loop/testdata/sessions/static-loop-in/01_loop-static-new.json index 69a7ab554..e196eaa41 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/01_loop-static-new.json +++ b/cmd/loop/testdata/sessions/static-loop-in/01_loop-static-new.json @@ -40,7 +40,8 @@ "event": "request", "message_type": "looprpc.NewStaticAddressRequest", "payload": { - "client_key": "" + "client_key": "", + "send_coins_request": null } } }, @@ -64,7 +65,8 @@ "lines": [ "{\n", " \"address\": \"bcrt1pfu9g59aqtxd39653f76y4c8z7r3t9tmcvrvhl57a3dgj3epdwxdqcd9fpw\",\n", - " \"expiry\": 14400\n", + " \"expiry\": 14400,\n", + " \"send_coins_response\": null\n", "}\n" ] } diff --git a/cmd/loop/testdata/sessions/static-loop-in/04_loop-static.json b/cmd/loop/testdata/sessions/static-loop-in/04_loop-static.json index 2b5335b75..322f930c1 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/04_loop-static.json +++ b/cmd/loop/testdata/sessions/static-loop-in/04_loop-static.json @@ -25,6 +25,7 @@ "\n", "COMMANDS:\n", " new, n Create a new static loop in address.\n", + " deposit Create and fund a new static loop in address.\n", " listunspent, l List unspent static address outputs.\n", " listdeposits Displays static address deposits. A filter can be applied to only show deposits in a specific state.\n", " listwithdrawals Display a summary of past withdrawals.\n", diff --git a/docs/loop.1 b/docs/loop.1 index 1e21905b8..9dcec55a0 100644 --- a/docs/loop.1 +++ b/docs/loop.1 @@ -427,6 +427,39 @@ Create a new static loop in address. .PP \fB--help, -h\fP: show help +.SS deposit +Create and fund a new static loop in address. + +.PP +\fB--amt\fP="": the number of bitcoin denominated in satoshis to send to the new static address (default: 0) + +.PP +\fB--coin_selection_strategy\fP="": (optional) the strategy to use for selecting coins. Possible values are 'largest', 'random', or 'global-config'. If either 'largest' or 'random' is specified, it will override the globally configured strategy in lnd.conf (default: global-config) + +.PP +\fB--conf_target\fP="": (optional) the number of blocks that the funding transaction should confirm in, will be used for fee estimation (default: 0) + +.PP +\fB--force, -f\fP: if set, the funding transaction will be broadcast without asking for confirmation + +.PP +\fB--help, -h\fP: show help + +.PP +\fB--label\fP="": (optional) a label for the funding transaction + +.PP +\fB--min_confs\fP="": (optional) the minimum number of confirmations each one of your outputs used for the funding transaction must satisfy (default: 1) + +.PP +\fB--sat_per_vbyte\fP="": (optional) a manual fee expressed in sat/vbyte that should be used when crafting the funding transaction (default: 0) + +.PP +\fB--sweepall\fP: if set, then the amount field should be unset. This indicates that the wallet will attempt to sweep all outputs within the wallet or all funds in selected utxos (when supplied) to the new static address + +.PP +\fB--utxo\fP="": a utxo specified as outpoint(tx:idx) which will be used as input for the funding transaction. This flag can be repeatedly used to specify multiple utxos as inputs. The selected utxos can either be entirely spent by specifying the sweepall flag or a specified amount can be spent in the utxos through the amt flag (default: []) + .SS listunspent, l List unspent static address outputs. diff --git a/docs/loop.md b/docs/loop.md index fc075b093..0ec93b58f 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -541,7 +541,7 @@ The following flags are supported: Create a new static loop in address. -Requests a new static loop in address from the server. Funds that are sent to this address will be locked by a 2:2 multisig between us and the loop server, or a timeout path that we can sweep once it opens up. The funds can either be cooperatively spent with a signature from the server or looped in. +Creates a new static loop in address. On a fresh installation loopd initializes the static-address generation during startup. Funds sent to the address will be locked by a 2:2 multisig between us and the loop server, or a timeout path that we can sweep once it opens up. The funds can either be cooperatively spent with a signature from the server or looped in. Usage: @@ -555,6 +555,33 @@ The following flags are supported: |-----------------|-------------|------|:-------------:| | `--help` (`-h`) | show help | bool | `false` | +### `static deposit` subcommand + +Create and fund a new static loop in address. + +Creates a new static loop in address and initiates a deposit by calling lnd's SendCoins API with the newly created address as the destination. + +Usage: + +```bash +$ loop [GLOBAL FLAGS] static deposit [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | +|---------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|:---------------:| +| `--amt="…"` | the number of bitcoin denominated in satoshis to send to the new static address | int | `0` | +| `--sweepall` | if set, then the amount field should be unset. This indicates that the wallet will attempt to sweep all outputs within the wallet or all funds in selected utxos (when supplied) to the new static address | bool | `false` | +| `--conf_target="…"` | (optional) the number of blocks that the funding transaction should confirm in, will be used for fee estimation | int | `0` | +| `--sat_per_vbyte="…"` | (optional) a manual fee expressed in sat/vbyte that should be used when crafting the funding transaction | uint | `0` | +| `--min_confs="…"` | (optional) the minimum number of confirmations each one of your outputs used for the funding transaction must satisfy | uint | `1` | +| `--force` (`-f`) | if set, the funding transaction will be broadcast without asking for confirmation | bool | `false` | +| `--coin_selection_strategy="…"` | (optional) the strategy to use for selecting coins. Possible values are 'largest', 'random', or 'global-config'. If either 'largest' or 'random' is specified, it will override the globally configured strategy in lnd.conf | string | `global-config` | +| `--utxo="…"` | a utxo specified as outpoint(tx:idx) which will be used as input for the funding transaction. This flag can be repeatedly used to specify multiple utxos as inputs. The selected utxos can either be entirely spent by specifying the sweepall flag or a specified amount can be spent in the utxos through the amt flag | string | `[]` | +| `--label="…"` | (optional) a label for the funding transaction | string | +| `--help` (`-h`) | show help | bool | `false` | + ### `static listunspent` subcommand (aliases: `l`) List unspent static address outputs. diff --git a/go.mod b/go.mod index 322497f84..4cad357cd 100644 --- a/go.mod +++ b/go.mod @@ -182,7 +182,7 @@ require ( golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect + golang.org/x/term v0.43.0 golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.44.0 // indirect diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 5ae481412..2bb9034b8 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -17,6 +17,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/aperture/l402" "github.com/lightninglabs/lndclient" @@ -38,6 +39,8 @@ import ( "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/taproot-assets/rfqmath" + lndlabels "github.com/lightningnetwork/lnd/labels" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/queue" @@ -45,6 +48,7 @@ import ( "github.com/lightningnetwork/lnd/zpay32" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" ) const ( @@ -1852,20 +1856,161 @@ func rpcInstantOut(instantOut *instantout.InstantOut) *looprpc.InstantOut { // NewStaticAddress is the rpc endpoint for loop clients to request a new static // address. func (s *swapClientServer) NewStaticAddress(ctx context.Context, - _ *looprpc.NewStaticAddressRequest) ( + req *looprpc.NewStaticAddressRequest) ( *looprpc.NewStaticAddressResponse, error) { + sendCoinsReq := req.GetSendCoinsRequest() + if err := validateStaticAddressSendCoinsRequest(sendCoinsReq); err != nil { + return nil, err + } + + if sendCoinsReq.GetAddr() != "" { + return s.fundExistingStaticAddress(ctx, sendCoinsReq) + } + staticAddress, expiry, err := s.staticAddressManager.NewAddress(ctx) if err != nil { return nil, err } + sendCoinsResp, err := s.sendCoinsToStaticAddress( + ctx, staticAddress.String(), sendCoinsReq, + ) + if err != nil { + return nil, fmt.Errorf("static address %s created, but "+ + "funding transaction failed: %w", staticAddress, err) + } + + return &looprpc.NewStaticAddressResponse{ + Address: staticAddress.String(), + Expiry: uint32(expiry), + SendCoinsResponse: sendCoinsResp, + }, nil +} + +func (s *swapClientServer) fundExistingStaticAddress(ctx context.Context, + req *lnrpc.SendCoinsRequest) (*looprpc.NewStaticAddressResponse, error) { + + staticAddress, expiry, err := s.staticAddressForDeposit(ctx, req.Addr) + if err != nil { + return nil, err + } + + sendCoinsResp, err := s.sendCoinsToStaticAddress( + ctx, staticAddress, req, + ) + if err != nil { + return nil, fmt.Errorf("static address %s funding transaction "+ + "failed: %w", staticAddress, err) + } + return &looprpc.NewStaticAddressResponse{ - Address: staticAddress.String(), - Expiry: uint32(expiry), + Address: staticAddress, + Expiry: expiry, + SendCoinsResponse: sendCoinsResp, }, nil } +func (s *swapClientServer) staticAddressForDeposit(_ context.Context, + addr string) (string, uint32, error) { + + staticAddress, err := btcutil.DecodeAddress(addr, s.lnd.ChainParams) + if err == nil && staticAddress.IsForNet(s.lnd.ChainParams) { + pkScript, scriptErr := txscript.PayToAddrScript(staticAddress) + if scriptErr == nil { + params := s.staticAddressManager.GetParameters(pkScript) + if params != nil { + return addr, params.Expiry, nil + } + } + } + + return "", 0, status.Errorf(codes.InvalidArgument, + "send_coins_request.addr is not a known static address") +} + +func validateStaticAddressSendCoinsRequest(req *lnrpc.SendCoinsRequest) error { + if req == nil { + return nil + } + + switch { + case req.Amount < 0: + return status.Error(codes.InvalidArgument, "send_coins_request."+ + "amount must be non-negative") + + case req.Amount == 0 && !req.SendAll: + return status.Error(codes.InvalidArgument, "send_coins_request "+ + "must set amount or send_all") + + case req.Amount != 0 && req.SendAll: + return status.Error(codes.InvalidArgument, "send_coins_request."+ + "amount cannot be set when send_all is true") + + case req.TargetConf < 0: + return status.Error(codes.InvalidArgument, "send_coins_request."+ + "target_conf must be non-negative") + + case req.SatPerByte < 0: //nolint:staticcheck + return status.Error(codes.InvalidArgument, "send_coins_request."+ + "sat_per_byte must be non-negative") + + case req.TargetConf != 0 && + (req.SatPerVbyte != 0 || req.SatPerByte != 0): //nolint:staticcheck + + return status.Error(codes.InvalidArgument, "send_coins_request "+ + "can set either target_conf or a fee rate, but not both") + + case req.SatPerVbyte != 0 && req.SatPerByte != 0: //nolint:staticcheck + return status.Error(codes.InvalidArgument, "send_coins_request "+ + "can set either sat_per_vbyte or sat_per_byte, but not "+ + "both") + + case req.MinConfs < 0: + return status.Error(codes.InvalidArgument, "send_coins_request."+ + "min_confs must be non-negative") + } + + if _, err := lnrpc.ExtractMinConfs( + req.MinConfs, req.SpendUnconfirmed, + ); err != nil { + return status.Errorf(codes.InvalidArgument, "send_coins_request "+ + "min_confs/spend_unconfirmed invalid: %v", err) + } + + if _, err := lndlabels.ValidateAPI(req.Label); err != nil { + return status.Errorf(codes.InvalidArgument, "send_coins_request "+ + "label invalid: %v", err) + } + + if _, err := lnrpc.UnmarshallCoinSelectionStrategy( + req.CoinSelectionStrategy, nil, + ); err != nil { + return status.Errorf(codes.InvalidArgument, "send_coins_request "+ + "coin_selection_strategy invalid: %v", err) + } + + return nil +} + +func (s *swapClientServer) sendCoinsToStaticAddress(ctx context.Context, + addr string, req *lnrpc.SendCoinsRequest) (*lnrpc.SendCoinsResponse, + error) { + + if req == nil { + return nil, nil + } + + sendCoinsReq := proto.Clone(req).(*lnrpc.SendCoinsRequest) + sendCoinsReq.Addr = addr + + rawCtx, timeout, rawClient := s.lnd.Client.RawClientWithMacAuth(ctx) + rawCtx, cancel := context.WithTimeout(rawCtx, timeout) + defer cancel() + + return rawClient.SendCoins(rawCtx, sendCoinsReq) +} + // ListUnspentDeposits returns a list of utxos behind the static address. func (s *swapClientServer) ListUnspentDeposits(ctx context.Context, req *looprpc.ListUnspentDepositsRequest) ( diff --git a/loopd/swapclient_server_staticaddr_test.go b/loopd/swapclient_server_staticaddr_test.go index 9478109bb..44b561c3b 100644 --- a/loopd/swapclient_server_staticaddr_test.go +++ b/loopd/swapclient_server_staticaddr_test.go @@ -2,22 +2,58 @@ package loopd import ( "context" + "strings" "testing" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/looprpc" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" mock_lnd "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet" "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" ) +type sendCoinsRPCClient struct { + lnrpc.LightningClient + + request *lnrpc.SendCoinsRequest + response *lnrpc.SendCoinsResponse +} + +func (c *sendCoinsRPCClient) SendCoins(_ context.Context, + req *lnrpc.SendCoinsRequest, _ ...grpc.CallOption) ( + *lnrpc.SendCoinsResponse, error) { + + c.request = proto.Clone(req).(*lnrpc.SendCoinsRequest) + + return c.response, nil +} + +type sendCoinsLightningClient struct { + lndclient.LightningClient + + rawClient lnrpc.LightningClient +} + +func (c *sendCoinsLightningClient) RawClientWithMacAuth( + ctx context.Context) (context.Context, time.Duration, + lnrpc.LightningClient) { + + return ctx, time.Second, c.rawClient +} + type staticAddrDepositStore struct { allDeposits []*deposit.Deposit byOutpoint map[string]*deposit.Deposit @@ -148,26 +184,238 @@ func newTestStaticAddressContext(t *testing.T) (*address.Manager, mock := mock_lnd.NewMockLnd() _, client := mock_lnd.CreateKey(1) _, server := mock_lnd.CreateKey(2) + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, 10, client, server, + ) + require.NoError(t, err) + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) addrStore := &mockAddressStore{ params: []*script.Parameters{{ ClientPubkey: client, ServerPubkey: server, Expiry: 10, - PkScript: []byte("pkscript"), + PkScript: pkScript, }}, } addrMgr, err := address.NewManager(&address.ManagerConfig{ - Store: addrStore, - WalletKit: mock.WalletKit, - ChainParams: mock.ChainParams, + Store: addrStore, + WalletKit: mock.WalletKit, + ChainParams: mock.ChainParams, + ChainNotifier: mock.ChainNotifier, }, 1) require.NoError(t, err) + initChan := make(chan struct{}) + go func() { + _ = addrMgr.Run(t.Context(), initChan) + }() + select { + case <-initChan: + case <-t.Context().Done(): + t.Fatal("address manager initialization canceled") + } + return addrMgr, mock } +func TestValidateStaticAddressSendCoinsRequest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + req *lnrpc.SendCoinsRequest + err string + }{ + { + name: "nil", + }, + { + name: "amount", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + }, + }, + { + name: "send all", + req: &lnrpc.SendCoinsRequest{ + SendAll: true, + }, + }, + { + name: "existing addr", + req: &lnrpc.SendCoinsRequest{ + Addr: "bcrt1ptestaddress", + Amount: 10_000, + }, + }, + { + name: "missing amount", + req: &lnrpc.SendCoinsRequest{}, + err: "must set amount or send_all", + }, + { + name: "negative amount", + req: &lnrpc.SendCoinsRequest{ + Amount: -1, + }, + err: "amount must be non-negative", + }, + { + name: "amount and send all", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + SendAll: true, + }, + err: "amount cannot be set when send_all is true", + }, + { + name: "target and fee rate", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + TargetConf: 6, + SatPerVbyte: 1, + SatPerByte: 0, + SendAll: false, + MinConfs: 1, + Outpoints: nil, + SpendUnconfirmed: false, + }, + err: "can set either target_conf or a fee rate", + }, + { + name: "both fee rates", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + SatPerVbyte: 1, + SatPerByte: 1, + }, + err: "can set either sat_per_vbyte or sat_per_byte", + }, + { + name: "negative min confs", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + MinConfs: -1, + }, + err: "min_confs must be non-negative", + }, + { + name: "min confs with spend unconfirmed", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + MinConfs: 1, + SpendUnconfirmed: true, + }, + err: "spend_unconfirmed invalid", + }, + { + name: "invalid label", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + Label: strings.Repeat("x", 501), + }, + err: "label invalid", + }, + { + name: "invalid coin selection strategy", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + CoinSelectionStrategy: lnrpc.CoinSelectionStrategy(99), + }, + err: "coin_selection_strategy invalid", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := validateStaticAddressSendCoinsRequest(test.req) + if test.err == "" { + require.NoError(t, err) + return + } + + require.ErrorContains(t, err, test.err) + }) + } +} + +func TestNewStaticAddressFundsGeneratedAddress(t *testing.T) { + t.Parallel() + + addrMgr, lnd := newTestStaticAddressContext(t) + rawClient := &sendCoinsRPCClient{ + response: &lnrpc.SendCoinsResponse{Txid: "funding-txid"}, + } + lnd.Client = &sendCoinsLightningClient{rawClient: rawClient} + server := &swapClientServer{ + staticAddressManager: addrMgr, + lnd: &lnd.LndServices, + } + + sendCoinsReq := &lnrpc.SendCoinsRequest{ + Amount: 100_000, + TargetConf: 6, + Label: "static-address-deposit", + MinConfs: 1, + CoinSelectionStrategy: lnrpc.CoinSelectionStrategy_STRATEGY_RANDOM, + Outpoints: []*lnrpc.OutPoint{{ + TxidStr: strings.Repeat("01", 32), + OutputIndex: 2, + }}, + } + resp, err := server.NewStaticAddress( + t.Context(), &looprpc.NewStaticAddressRequest{ + SendCoinsRequest: sendCoinsReq, + }, + ) + require.NoError(t, err) + require.NotEmpty(t, resp.Address) + require.Equal(t, "funding-txid", resp.GetSendCoinsResponse().GetTxid()) + + expectedReq := proto.Clone(sendCoinsReq).(*lnrpc.SendCoinsRequest) + expectedReq.Addr = resp.Address + require.True(t, proto.Equal(expectedReq, rawClient.request)) + require.Empty(t, sendCoinsReq.Addr) +} + +func TestStaticAddressForDeposit(t *testing.T) { + t.Parallel() + + ctx := context.Background() + addrMgr, lnd := newTestStaticAddressContext(t) + server := &swapClientServer{ + staticAddressManager: addrMgr, + lnd: &lnd.LndServices, + } + + addresses, err := addrMgr.GetAllAddresses(ctx) + require.NoError(t, err) + require.Len(t, addresses, 1) + + expectedAddr, err := addrMgr.GetTaprootAddress( + addresses[0].ClientPubkey, addresses[0].ServerPubkey, + int64(addresses[0].Expiry), + ) + require.NoError(t, err) + + addr, expiry, err := server.staticAddressForDeposit( + ctx, expectedAddr.String(), + ) + require.NoError(t, err) + require.Equal(t, expectedAddr.String(), addr) + require.Equal(t, addresses[0].Expiry, expiry) + + _, _, err = server.staticAddressForDeposit( + ctx, "bcrt1punknownstaticaddress", + ) + require.ErrorContains(t, err, "not a known static address") +} + // TestListStaticAddressDepositsReturnsVisibleDeposits verifies normal deposit // listings include visible deposit records. func TestListStaticAddressDepositsReturnsVisibleDeposits(t *testing.T) { diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index 923a87c38..46f42a6bb 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -4928,9 +4928,14 @@ func (x *InstantOut) GetSweepTxId() string { type NewStaticAddressRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The client's public key for the 2-of-2 MuSig2 taproot static address. - ClientKey []byte `protobuf:"bytes,1,opt,name=client_key,json=clientKey,proto3" json:"client_key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ClientKey []byte `protobuf:"bytes,1,opt,name=client_key,json=clientKey,proto3" json:"client_key,omitempty"` + // If set, loopd initiates a deposit by calling lnd's SendCoins API. If the + // request's addr field is empty, loopd creates and funds a new static + // address. If addr is set, it must be an existing static address known to + // loopd. + SendCoinsRequest *lnrpc.SendCoinsRequest `protobuf:"bytes,2,opt,name=send_coins_request,json=sendCoinsRequest,proto3" json:"send_coins_request,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *NewStaticAddressRequest) Reset() { @@ -4970,14 +4975,23 @@ func (x *NewStaticAddressRequest) GetClientKey() []byte { return nil } +func (x *NewStaticAddressRequest) GetSendCoinsRequest() *lnrpc.SendCoinsRequest { + if x != nil { + return x.SendCoinsRequest + } + return nil +} + type NewStaticAddressResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The taproot static address. Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` // The CSV expiry of the static address. - Expiry uint32 `protobuf:"varint,2,opt,name=expiry,proto3" json:"expiry,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Expiry uint32 `protobuf:"varint,2,opt,name=expiry,proto3" json:"expiry,omitempty"` + // The response from lnd's SendCoins API, if a deposit was initiated. + SendCoinsResponse *lnrpc.SendCoinsResponse `protobuf:"bytes,3,opt,name=send_coins_response,json=sendCoinsResponse,proto3" json:"send_coins_response,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *NewStaticAddressResponse) Reset() { @@ -5024,6 +5038,13 @@ func (x *NewStaticAddressResponse) GetExpiry() uint32 { return 0 } +func (x *NewStaticAddressResponse) GetSendCoinsResponse() *lnrpc.SendCoinsResponse { + if x != nil { + return x.SendCoinsResponse + } + return nil +} + type ListUnspentDepositsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The number of minimum confirmations a utxo must have to be listed. @@ -7183,13 +7204,15 @@ const file_client_proto_rawDesc = "" + "\x05state\x18\x02 \x01(\tR\x05state\x12\x16\n" + "\x06amount\x18\x03 \x01(\x04R\x06amount\x12'\n" + "\x0freservation_ids\x18\x04 \x03(\fR\x0ereservationIds\x12\x1e\n" + - "\vsweep_tx_id\x18\x05 \x01(\tR\tsweepTxId\"8\n" + + "\vsweep_tx_id\x18\x05 \x01(\tR\tsweepTxId\"\x7f\n" + "\x17NewStaticAddressRequest\x12\x1d\n" + "\n" + - "client_key\x18\x01 \x01(\fR\tclientKey\"L\n" + + "client_key\x18\x01 \x01(\fR\tclientKey\x12E\n" + + "\x12send_coins_request\x18\x02 \x01(\v2\x17.lnrpc.SendCoinsRequestR\x10sendCoinsRequest\"\x96\x01\n" + "\x18NewStaticAddressResponse\x12\x18\n" + "\aaddress\x18\x01 \x01(\tR\aaddress\x12\x16\n" + - "\x06expiry\x18\x02 \x01(\rR\x06expiry\"V\n" + + "\x06expiry\x18\x02 \x01(\rR\x06expiry\x12H\n" + + "\x13send_coins_response\x18\x03 \x01(\v2\x18.lnrpc.SendCoinsResponseR\x11sendCoinsResponse\"V\n" + "\x1aListUnspentDepositsRequest\x12\x1b\n" + "\tmin_confs\x18\x01 \x01(\x05R\bminConfs\x12\x1b\n" + "\tmax_confs\x18\x02 \x01(\x05R\bmaxConfs\"B\n" + @@ -7554,7 +7577,9 @@ var file_client_proto_goTypes = []any{ nil, // 91: looprpc.LiquidityParameters.EasyAssetParamsEntry (*lnrpc.OpenChannelRequest)(nil), // 92: lnrpc.OpenChannelRequest (*swapserverrpc.RouteHint)(nil), // 93: looprpc.RouteHint - (*lnrpc.OutPoint)(nil), // 94: lnrpc.OutPoint + (*lnrpc.SendCoinsRequest)(nil), // 94: lnrpc.SendCoinsRequest + (*lnrpc.SendCoinsResponse)(nil), // 95: lnrpc.SendCoinsResponse + (*lnrpc.OutPoint)(nil), // 96: lnrpc.OutPoint } var file_client_proto_depIdxs = []int32{ 92, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest @@ -7596,92 +7621,94 @@ var file_client_proto_depIdxs = []int32{ 51, // 36: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified 57, // 37: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation 64, // 38: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut - 69, // 39: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo - 94, // 40: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint - 7, // 41: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState - 80, // 42: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit - 81, // 43: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal - 82, // 44: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap - 7, // 45: looprpc.Deposit.state:type_name -> looprpc.DepositState - 80, // 46: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit - 8, // 47: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState - 80, // 48: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit - 93, // 49: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint - 80, // 50: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit - 87, // 51: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint - 87, // 52: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint - 46, // 53: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams - 14, // 54: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest - 15, // 55: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest - 17, // 56: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest - 19, // 57: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest - 22, // 58: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest - 27, // 59: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest - 53, // 60: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest - 28, // 61: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest - 31, // 62: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest - 28, // 63: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest - 31, // 64: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest - 34, // 65: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest - 36, // 66: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest - 36, // 67: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest - 38, // 68: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest - 42, // 69: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest - 12, // 70: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest - 44, // 71: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest - 48, // 72: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest - 50, // 73: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest - 55, // 74: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest - 58, // 75: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest - 60, // 76: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest - 62, // 77: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest - 65, // 78: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest - 67, // 79: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest - 70, // 80: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest - 72, // 81: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest - 74, // 82: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest - 76, // 83: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest - 78, // 84: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest - 83, // 85: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest - 10, // 86: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest - 16, // 87: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse - 16, // 88: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse - 18, // 89: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus - 21, // 90: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse - 23, // 91: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse - 18, // 92: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus - 54, // 93: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse - 30, // 94: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse - 33, // 95: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse - 29, // 96: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse - 32, // 97: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse - 35, // 98: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse - 37, // 99: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse - 37, // 100: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse - 39, // 101: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse - 43, // 102: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse - 13, // 103: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse - 45, // 104: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters - 49, // 105: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse - 52, // 106: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse - 56, // 107: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse - 59, // 108: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse - 61, // 109: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse - 63, // 110: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse - 66, // 111: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse - 68, // 112: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse - 71, // 113: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse - 73, // 114: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse - 75, // 115: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse - 77, // 116: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse - 79, // 117: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse - 84, // 118: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse - 11, // 119: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse - 87, // [87:120] is the sub-list for method output_type - 54, // [54:87] is the sub-list for method input_type - 54, // [54:54] is the sub-list for extension type_name - 54, // [54:54] is the sub-list for extension extendee - 0, // [0:54] is the sub-list for field type_name + 94, // 39: looprpc.NewStaticAddressRequest.send_coins_request:type_name -> lnrpc.SendCoinsRequest + 95, // 40: looprpc.NewStaticAddressResponse.send_coins_response:type_name -> lnrpc.SendCoinsResponse + 69, // 41: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo + 96, // 42: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint + 7, // 43: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState + 80, // 44: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit + 81, // 45: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal + 82, // 46: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap + 7, // 47: looprpc.Deposit.state:type_name -> looprpc.DepositState + 80, // 48: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit + 8, // 49: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState + 80, // 50: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit + 93, // 51: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint + 80, // 52: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit + 87, // 53: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint + 87, // 54: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint + 46, // 55: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams + 14, // 56: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest + 15, // 57: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest + 17, // 58: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest + 19, // 59: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest + 22, // 60: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest + 27, // 61: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest + 53, // 62: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest + 28, // 63: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest + 31, // 64: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest + 28, // 65: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest + 31, // 66: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest + 34, // 67: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest + 36, // 68: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest + 36, // 69: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest + 38, // 70: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest + 42, // 71: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest + 12, // 72: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest + 44, // 73: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest + 48, // 74: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest + 50, // 75: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest + 55, // 76: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest + 58, // 77: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest + 60, // 78: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest + 62, // 79: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest + 65, // 80: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest + 67, // 81: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest + 70, // 82: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest + 72, // 83: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest + 74, // 84: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest + 76, // 85: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest + 78, // 86: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest + 83, // 87: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest + 10, // 88: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest + 16, // 89: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse + 16, // 90: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse + 18, // 91: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus + 21, // 92: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse + 23, // 93: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse + 18, // 94: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus + 54, // 95: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse + 30, // 96: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse + 33, // 97: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse + 29, // 98: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse + 32, // 99: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse + 35, // 100: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse + 37, // 101: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse + 37, // 102: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse + 39, // 103: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse + 43, // 104: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse + 13, // 105: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse + 45, // 106: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters + 49, // 107: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse + 52, // 108: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse + 56, // 109: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse + 59, // 110: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse + 61, // 111: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse + 63, // 112: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse + 66, // 113: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse + 68, // 114: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse + 71, // 115: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse + 73, // 116: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse + 75, // 117: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse + 77, // 118: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse + 79, // 119: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse + 84, // 120: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse + 11, // 121: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse + 89, // [89:122] is the sub-list for method output_type + 56, // [56:89] is the sub-list for method input_type + 56, // [56:56] is the sub-list for extension type_name + 56, // [56:56] is the sub-list for extension extendee + 0, // [0:56] is the sub-list for field type_name } func init() { file_client_proto_init() } diff --git a/looprpc/client.proto b/looprpc/client.proto index 36332dcdd..94fa78703 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -1796,6 +1796,14 @@ message NewStaticAddressRequest { The client's public key for the 2-of-2 MuSig2 taproot static address. */ bytes client_key = 1; + + /* + If set, loopd initiates a deposit by calling lnd's SendCoins API. If the + request's addr field is empty, loopd creates and funds a new static + address. If addr is set, it must be an existing static address known to + loopd. + */ + lnrpc.SendCoinsRequest send_coins_request = 2; } message NewStaticAddressResponse { @@ -1808,6 +1816,11 @@ message NewStaticAddressResponse { The CSV expiry of the static address. */ uint32 expiry = 2; + + /* + The response from lnd's SendCoins API, if a deposit was initiated. + */ + lnrpc.SendCoinsResponse send_coins_response = 3; } message ListUnspentDepositsRequest { diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index 2332cfc53..2b8851dd8 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -1204,6 +1204,16 @@ } } }, + "lnrpcCoinSelectionStrategy": { + "type": "string", + "enum": [ + "STRATEGY_USE_GLOBAL_CONFIG", + "STRATEGY_LARGEST", + "STRATEGY_RANDOM" + ], + "default": "STRATEGY_USE_GLOBAL_CONFIG", + "description": " - STRATEGY_USE_GLOBAL_CONFIG: Use the coin selection strategy defined in the global configuration\n(lnd.conf).\n - STRATEGY_LARGEST: Select the largest available coins first during coin selection.\n - STRATEGY_RANDOM: Randomly select the available coins during coin selection." + }, "lnrpcCommitmentType": { "type": "string", "enum": [ @@ -1436,6 +1446,73 @@ } } }, + "lnrpcSendCoinsRequest": { + "type": "object", + "properties": { + "addr": { + "type": "string", + "title": "The address to send coins to" + }, + "amount": { + "type": "string", + "format": "int64", + "title": "The amount in satoshis to send" + }, + "target_conf": { + "type": "integer", + "format": "int32", + "description": "The target number of blocks that this transaction should be confirmed\nby." + }, + "sat_per_vbyte": { + "type": "string", + "format": "uint64", + "description": "A manual fee rate set in sat/vbyte that should be used when crafting the\ntransaction." + }, + "sat_per_byte": { + "type": "string", + "format": "int64", + "description": "Deprecated, use sat_per_vbyte.\nA manual fee rate set in sat/vbyte that should be used when crafting the\ntransaction." + }, + "send_all": { + "type": "boolean", + "description": "If set, the amount field should be unset. It indicates lnd will send all\nwallet coins or all selected coins to the specified address." + }, + "label": { + "type": "string", + "description": "An optional label for the transaction, limited to 500 characters." + }, + "min_confs": { + "type": "integer", + "format": "int32", + "description": "The minimum number of confirmations each one of your outputs used for\nthe transaction must satisfy." + }, + "spend_unconfirmed": { + "type": "boolean", + "description": "Whether unconfirmed outputs should be used as inputs for the transaction." + }, + "coin_selection_strategy": { + "$ref": "#/definitions/lnrpcCoinSelectionStrategy", + "description": "The strategy to use for selecting coins." + }, + "outpoints": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/lnrpcOutPoint" + }, + "description": "A list of selected outpoints as inputs for the transaction." + } + } + }, + "lnrpcSendCoinsResponse": { + "type": "object", + "properties": { + "txid": { + "type": "string", + "title": "The transaction ID of the transaction" + } + } + }, "looprpcAbandonSwapResponse": { "type": "object" }, @@ -2531,6 +2608,10 @@ "type": "string", "format": "byte", "description": "The client's public key for the 2-of-2 MuSig2 taproot static address." + }, + "send_coins_request": { + "$ref": "#/definitions/lnrpcSendCoinsRequest", + "description": "If set, loopd initiates a deposit by calling lnd's SendCoins API. If the\nrequest's addr field is empty, loopd creates and funds a new static\naddress. If addr is set, it must be an existing static address known to\nloopd." } } }, @@ -2545,6 +2626,10 @@ "type": "integer", "format": "int64", "description": "The CSV expiry of the static address." + }, + "send_coins_response": { + "$ref": "#/definitions/lnrpcSendCoinsResponse", + "description": "The response from lnd's SendCoins API, if a deposit was initiated." } } }, diff --git a/looprpc/perms.go b/looprpc/perms.go index 9187920a7..c046e9180 100644 --- a/looprpc/perms.go +++ b/looprpc/perms.go @@ -82,7 +82,7 @@ var RequiredPermissions = map[string][]bakery.Op{ }}, "/looprpc.SwapClient/NewStaticAddress": {{ Entity: "swap", - Action: "read", + Action: "execute", }, { Entity: "loop", Action: "in", From aeefe2bca62b7ee2ddb96f367bcdc030ad1f8d73 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 27 Aug 2026 10:47:32 +0200 Subject: [PATCH 15/30] staticaddr: expose addresses in deposit listings Include the owning static address in every deposit RPC response and CLI listing. Users can distinguish deposits created by different receive and change addresses without reconstructing scripts externally. Calculate blocks until expiry from each deposit owner instead of the legacy root address, and reject deposits whose owning parameters are missing. Centralize deposit response conversion and update generated RPC artifacts, regression coverage, and command replay fixtures. --- ..._loop-static-listdeposits-withdrawing.json | 1 + ...03_loop-static-listdeposits-withdrawn.json | 4 + ...05_loop-static-listdeposits-looped_in.json | 1 + .../11_loop-static-listdeposits-failed.json | 6 + ...stdeposits-channel_published-nonempty.json | 2 + .../10_loop-static-listdeposits.json | 1 + .../static-loop-in/15_loop-static-in.json | 1 + ...-static-in-positional-payment-timeout.json | 1 + .../23_loop-static-in-max-swap-fee-both.json | 1 + ...op-static-in-max-swap-fee-sat-success.json | 1 + .../25_loop-static-in-low-conf-utxo.json | 1 + .../26_loop-static-in-auto-unconfirmed.json | 1 + .../02_loop-static-listwithdrawals.json | 1 + loopd/swapclient_server.go | 190 ++++++++++++------ loopd/swapclient_server_staticaddr_test.go | 174 +++++++++++++++- loopd/swapclient_server_test.go | 27 ++- looprpc/client.pb.go | 16 +- looprpc/client.proto | 5 + looprpc/client.swagger.json | 4 + 19 files changed, 361 insertions(+), 77 deletions(-) diff --git a/cmd/loop/testdata/sessions/static-filters/02_loop-static-listdeposits-withdrawing.json b/cmd/loop/testdata/sessions/static-filters/02_loop-static-listdeposits-withdrawing.json index 99186eb26..5876bff6d 100644 --- a/cmd/loop/testdata/sessions/static-filters/02_loop-static-listdeposits-withdrawing.json +++ b/cmd/loop/testdata/sessions/static-filters/02_loop-static-listdeposits-withdrawing.json @@ -65,6 +65,7 @@ " \"id\": \"bb7f050df0b7c3e1fe61010e10ad45e30ddf7acd301fa6e05a2ddb825b5c2efb\",\n", " \"outpoint\": \"56cd081a3a6eadf25b7d3fe0b61207389352ed69a622d2ec28c5d669bf6a5313:0\",\n", " \"state\": \"WITHDRAWING\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-filters/03_loop-static-listdeposits-withdrawn.json b/cmd/loop/testdata/sessions/static-filters/03_loop-static-listdeposits-withdrawn.json index 79c024585..ab221508b 100644 --- a/cmd/loop/testdata/sessions/static-filters/03_loop-static-listdeposits-withdrawn.json +++ b/cmd/loop/testdata/sessions/static-filters/03_loop-static-listdeposits-withdrawn.json @@ -92,6 +92,7 @@ " \"id\": \"68262a104c9ec325de6bec37b8e31bd875bbd2f5f0b9ce2da20cf0bd636fc448\",\n", " \"outpoint\": \"edcdab8f0b1138d853a453b8b7a5ac3c694bd53ad38b7ccf062e45f99440e6e6:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -101,6 +102,7 @@ " \"id\": \"86b5e2cdf9694c8e7398e42afde109766d7cd2142203905ba63fbd0eb1370ef3\",\n", " \"outpoint\": \"bb358e4f73ae97c4e2d99c6d64e852bba7cf56e13105b05d1200b8ae1796665e:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -110,6 +112,7 @@ " \"id\": \"6c290f7536ea5097946afffac6a69906a26d775823ebbacedfe6f2d69c0745e4\",\n", " \"outpoint\": \"5eaa7dd7a291665393eddf5dece91feef901f22665933cce7a0732a9b81c3001:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -119,6 +122,7 @@ " \"id\": \"0182b4d895b1c467290ae7b5c6c42ff76b2a4225807a94211c973170d5a883eb\",\n", " \"outpoint\": \"7e6360d6e6a394cfd096adf0bfe1275c5a83541eb573e90e463a78dc715f8894:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-filters/05_loop-static-listdeposits-looped_in.json b/cmd/loop/testdata/sessions/static-filters/05_loop-static-listdeposits-looped_in.json index e935ed5eb..92d14aa1a 100644 --- a/cmd/loop/testdata/sessions/static-filters/05_loop-static-listdeposits-looped_in.json +++ b/cmd/loop/testdata/sessions/static-filters/05_loop-static-listdeposits-looped_in.json @@ -65,6 +65,7 @@ " \"id\": \"8fbd6da2f945de2905aa7fa93860744d9387d3464484360e96e467a51de3bc9d\",\n", " \"outpoint\": \"9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0\",\n", " \"state\": \"LOOPED_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"84302337424036419396ab7964dd78b85b1a481a9f1db73db5cddee57c2443e7\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-filters/11_loop-static-listdeposits-failed.json b/cmd/loop/testdata/sessions/static-filters/11_loop-static-listdeposits-failed.json index 407287ffd..6dbf9009d 100644 --- a/cmd/loop/testdata/sessions/static-filters/11_loop-static-listdeposits-failed.json +++ b/cmd/loop/testdata/sessions/static-filters/11_loop-static-listdeposits-failed.json @@ -110,6 +110,7 @@ " \"id\": \"68262a104c9ec325de6bec37b8e31bd875bbd2f5f0b9ce2da20cf0bd636fc448\",\n", " \"outpoint\": \"edcdab8f0b1138d853a453b8b7a5ac3c694bd53ad38b7ccf062e45f99440e6e6:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -119,6 +120,7 @@ " \"id\": \"86b5e2cdf9694c8e7398e42afde109766d7cd2142203905ba63fbd0eb1370ef3\",\n", " \"outpoint\": \"bb358e4f73ae97c4e2d99c6d64e852bba7cf56e13105b05d1200b8ae1796665e:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -128,6 +130,7 @@ " \"id\": \"6c290f7536ea5097946afffac6a69906a26d775823ebbacedfe6f2d69c0745e4\",\n", " \"outpoint\": \"5eaa7dd7a291665393eddf5dece91feef901f22665933cce7a0732a9b81c3001:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -137,6 +140,7 @@ " \"id\": \"0182b4d895b1c467290ae7b5c6c42ff76b2a4225807a94211c973170d5a883eb\",\n", " \"outpoint\": \"7e6360d6e6a394cfd096adf0bfe1275c5a83541eb573e90e463a78dc715f8894:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -146,6 +150,7 @@ " \"id\": \"8fbd6da2f945de2905aa7fa93860744d9387d3464484360e96e467a51de3bc9d\",\n", " \"outpoint\": \"9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0\",\n", " \"state\": \"LOOPED_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"84302337424036419396ab7964dd78b85b1a481a9f1db73db5cddee57c2443e7\",\n", " \"value\": \"500000\"\n", " },\n", @@ -155,6 +160,7 @@ " \"id\": \"bb7f050df0b7c3e1fe61010e10ad45e30ddf7acd301fa6e05a2ddb825b5c2efb\",\n", " \"outpoint\": \"56cd081a3a6eadf25b7d3fe0b61207389352ed69a622d2ec28c5d669bf6a5313:0\",\n", " \"state\": \"WITHDRAWING\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-filters/14_loop-static-listdeposits-channel_published-nonempty.json b/cmd/loop/testdata/sessions/static-filters/14_loop-static-listdeposits-channel_published-nonempty.json index 1cb79c441..12689a556 100644 --- a/cmd/loop/testdata/sessions/static-filters/14_loop-static-listdeposits-channel_published-nonempty.json +++ b/cmd/loop/testdata/sessions/static-filters/14_loop-static-listdeposits-channel_published-nonempty.json @@ -77,6 +77,7 @@ " \"id\": \"7a7cbe9b90f23d47aa92eb10a9d323f7ace6e9eaab5b77379c63422c15da19c8\",\n", " \"outpoint\": \"0e70673c1da3343648c26f779555346f30d235314838b1160826d0d5c29b4fba:1\",\n", " \"state\": \"CHANNEL_PUBLISHED\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -86,6 +87,7 @@ " \"id\": \"ff9a43b2082f906a2e2758934220c4ce32393eb2823b292517ae081e16daded9\",\n", " \"outpoint\": \"d2d6e50f157f0d31b8688a4af4f064edf3454714e92369b2c8c4d82477edbaca:0\",\n", " \"state\": \"CHANNEL_PUBLISHED\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"1000000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/10_loop-static-listdeposits.json b/cmd/loop/testdata/sessions/static-loop-in/10_loop-static-listdeposits.json index ff4d0d14d..e22efed83 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/10_loop-static-listdeposits.json +++ b/cmd/loop/testdata/sessions/static-loop-in/10_loop-static-listdeposits.json @@ -61,6 +61,7 @@ " \"id\": \"ea6abbf0571c0ba82117ae9f2086614eacea8b2913dc0544b70c00de78353e71\",\n", " \"outpoint\": \"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0\",\n", " \"state\": \"DEPOSITED\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"2500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json b/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json index 5acb8ddf0..49c6a9ef1 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json +++ b/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json @@ -228,6 +228,7 @@ " \"id\": \"ea6abbf0571c0ba82117ae9f2086614eacea8b2913dc0544b70c00de78353e71\",\n", " \"outpoint\": \"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"2500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json b/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json index b997322b0..666c2a7ea 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json +++ b/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json @@ -209,6 +209,7 @@ " \"id\": \"8fbd6da2f945de2905aa7fa93860744d9387d3464484360e96e467a51de3bc9d\",\n", " \"outpoint\": \"9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json b/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json index 02b5a3ac4..690c48d2f 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json +++ b/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json @@ -244,6 +244,7 @@ " \"id\": \"82771323e95dca403d966f70a88be39ef0a475ef6aa78694044ba9b87304ac63\",\n", " \"outpoint\": \"da52bf383c4fe5c684221c311fc5756ccaee211b6c6e6f5ccc159622a6039271:1\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json b/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json index f2994fde2..69ff4b8ed 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json +++ b/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json @@ -235,6 +235,7 @@ " \"id\": \"d8a58536d8472873b9e2e1657468328360fda0b94231cfc5e29900cab735da84\",\n", " \"outpoint\": \"f2280f0f086273be73bde92fd9b982208338a5ecebbe93b83b00c77c4d2f8d1b:0\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"550000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/25_loop-static-in-low-conf-utxo.json b/cmd/loop/testdata/sessions/static-loop-in/25_loop-static-in-low-conf-utxo.json index 3093fb9d6..37aae5a7a 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/25_loop-static-in-low-conf-utxo.json +++ b/cmd/loop/testdata/sessions/static-loop-in/25_loop-static-in-low-conf-utxo.json @@ -159,6 +159,7 @@ " \"id\": \"ea6abbf0571c0ba82117ae9f2086614eacea8b2913dc0544b70c00de78353e71\",\n", " \"outpoint\": \"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"2500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/26_loop-static-in-auto-unconfirmed.json b/cmd/loop/testdata/sessions/static-loop-in/26_loop-static-in-auto-unconfirmed.json index dae0c0a05..d254a4508 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/26_loop-static-in-auto-unconfirmed.json +++ b/cmd/loop/testdata/sessions/static-loop-in/26_loop-static-in-auto-unconfirmed.json @@ -214,6 +214,7 @@ " \"id\": \"8fbd6da2f945de2905aa7fa93860744d9387d3464484360e96e467a51de3bc9d\",\n", " \"outpoint\": \"9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static/02_loop-static-listwithdrawals.json b/cmd/loop/testdata/sessions/static/02_loop-static-listwithdrawals.json index 15b5d4e1f..211fc7a8f 100644 --- a/cmd/loop/testdata/sessions/static/02_loop-static-listwithdrawals.json +++ b/cmd/loop/testdata/sessions/static/02_loop-static-listwithdrawals.json @@ -73,6 +73,7 @@ " \"id\": \"68262a104c9ec325de6bec37b8e31bd875bbd2f5f0b9ce2da20cf0bd636fc448\",\n", " \"outpoint\": \"edcdab8f0b1138d853a453b8b7a5ac3c694bd53ad38b7ccf062e45f99440e6e6:0\",\n", " \"state\": \"WITHDRAWING\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 2bb9034b8..5c20f989e 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -2196,7 +2196,10 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context, f := func(d *deposit.Deposit) bool { return slices.Contains(outpoints, d.OutPoint.String()) } - filteredDeposits = filter(allDeposits, f) + filteredDeposits, err = s.filterDeposits(allDeposits, f) + if err != nil { + return nil, err + } if len(outpoints) != len(filteredDeposits) { return nil, fmt.Errorf("not all outpoints found in " + @@ -2212,11 +2215,14 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context, return d.IsInState(toServerState(req.StateFilter)) } - filteredDeposits = filter(allDeposits, f) + filteredDeposits, err = s.filterDeposits(allDeposits, f) + if err != nil { + return nil, err + } } // Calculate the blocks until expiry for each deposit. - err = s.populateBlocksUntilExpiry(ctx, filteredDeposits) + err = s.populateBlocksUntilExpiry(ctx, allDeposits, filteredDeposits) if err != nil { infof("Failed to populate blocks until expiry: %v", err) } @@ -2245,26 +2251,11 @@ func (s *swapClientServer) ListStaticAddressWithdrawals(ctx context.Context, []*looprpc.StaticAddressWithdrawal, 0, len(withdrawals), ) for _, w := range withdrawals { - deposits := make([]*looprpc.Deposit, 0, len(w.Deposits)) - for _, d := range w.Deposits { - deposits = append(deposits, &looprpc.Deposit{ - Id: d.ID[:], - Outpoint: d.OutPoint.String(), - Value: int64(d.Value), - ConfirmationHeight: d.GetConfirmationHeight(), - State: toClientDepositState( - d.GetState(), - ), - }) - } - withdrawal := &looprpc.StaticAddressWithdrawal{ - TxId: w.TxID.String(), - Deposits: deposits, - TotalDepositAmountSatoshis: int64(w.TotalDepositAmount), - WithdrawnAmountSatoshis: int64(w.WithdrawnAmount), - ChangeAmountSatoshis: int64(w.ChangeAmount), - ConfirmationHeight: uint32(w.ConfirmationHeight), + withdrawal, err := s.rpcStaticAddressWithdrawal(w) + if err != nil { + return nil, err } + clientWithdrawals = append(clientWithdrawals, withdrawal) } @@ -2273,6 +2264,29 @@ func (s *swapClientServer) ListStaticAddressWithdrawals(ctx context.Context, }, nil } +func (s *swapClientServer) rpcStaticAddressWithdrawal( + w withdraw.Withdrawal) (*looprpc.StaticAddressWithdrawal, error) { + + deposits := make([]*looprpc.Deposit, 0, len(w.Deposits)) + for _, d := range w.Deposits { + rpcDeposit, err := s.rpcDeposit(d) + if err != nil { + return nil, err + } + + deposits = append(deposits, rpcDeposit) + } + + return &looprpc.StaticAddressWithdrawal{ + TxId: w.TxID.String(), + Deposits: deposits, + TotalDepositAmountSatoshis: int64(w.TotalDepositAmount), + WithdrawnAmountSatoshis: int64(w.WithdrawnAmount), + ChangeAmountSatoshis: int64(w.ChangeAmount), + ConfirmationHeight: uint32(w.ConfirmationHeight), + }, nil +} + // ListStaticAddressSwaps returns a list of all swaps that are currently pending // or previously succeeded. func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context, @@ -2294,13 +2308,6 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context, return nil, err } - addrParams, err := s.staticAddressManager.GetStaticAddressParameters( - ctx, - ) - if err != nil { - return nil, err - } - // Fetch all deposits at once and index them by swap hash for a quick // lookup. allDeposits, err := s.depositManager.GetAllDeposits(ctx) @@ -2341,22 +2348,23 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context, if ds, ok := depositsBySwap[swp.SwapHash]; ok { protoDeposits = make([]*looprpc.Deposit, 0, len(ds)) for _, d := range ds { - state := toClientDepositState(d.GetState()) confirmationHeight := d.GetConfirmationHeight() + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static "+ + "address parameters for deposit %v", + d.OutPoint) + } blocksUntilExpiry := depositBlocksUntilExpiry( - confirmationHeight, addrParams.Expiry, + confirmationHeight, + d.AddressParams.Expiry, int64(lndInfo.BlockHeight), ) - pd := &looprpc.Deposit{ - Id: d.ID[:], - State: state, - Outpoint: d.OutPoint.String(), - Value: int64(d.Value), - ConfirmationHeight: confirmationHeight, - SwapHash: d.SwapHash[:], - BlocksUntilExpiry: blocksUntilExpiry, + pd, err := s.rpcDeposit(d) + if err != nil { + return nil, err } + pd.BlocksUntilExpiry = blocksUntilExpiry protoDeposits = append(protoDeposits, pd) } } @@ -2674,12 +2682,22 @@ func (s *swapClientServer) StaticAddressLoopIn(ctx context.Context, return nil, err } + return s.rpcStaticAddressLoopInResponse(ctx, loopIn) +} + +func (s *swapClientServer) rpcStaticAddressLoopInResponse(ctx context.Context, + loopIn *loopin.StaticAddressLoopIn) ( + *looprpc.StaticAddressLoopInResponse, error) { + // Build a list of used deposits for the response. - usedDeposits := filter( + usedDeposits, err := s.filterDeposits( loopIn.Deposits, func(d *deposit.Deposit) bool { return true }, ) + if err != nil { + return nil, err + } - err = s.populateBlocksUntilExpiry(ctx, usedDeposits) + err = s.populateBlocksUntilExpiry(ctx, loopIn.Deposits, usedDeposits) if err != nil { infof("Failed to populate blocks until expiry: %v", err) } @@ -2721,21 +2739,32 @@ func (s *swapClientServer) StaticAddressLoopIn(ctx context.Context, // Calculate the blocks until expiry for each deposit and return the modified // StaticAddressLoopInResponse. func (s *swapClientServer) populateBlocksUntilExpiry(ctx context.Context, - deposits []*looprpc.Deposit) error { + sourceDeposits []*deposit.Deposit, deposits []*looprpc.Deposit) error { lndInfo, err := s.lnd.Client.GetInfo(ctx) if err != nil { return err } - bestBlockHeight := int64(lndInfo.BlockHeight) - params, err := s.staticAddressManager.GetStaticAddressParameters(ctx) - if err != nil { - return err + expiryByOutpoint := make(map[string]uint32, len(sourceDeposits)) + for _, d := range sourceDeposits { + if d.AddressParams == nil { + return fmt.Errorf("missing static address parameters for "+ + "deposit %v", d.OutPoint) + } + + expiryByOutpoint[d.OutPoint.String()] = d.AddressParams.Expiry } + + bestBlockHeight := int64(lndInfo.BlockHeight) for i := range len(deposits) { + expiry, ok := expiryByOutpoint[deposits[i].Outpoint] + if !ok { + continue + } + deposits[i].BlocksUntilExpiry = depositBlocksUntilExpiry( - deposits[i].ConfirmationHeight, params.Expiry, + deposits[i].ConfirmationHeight, expiry, bestBlockHeight, ) } @@ -2784,35 +2813,66 @@ func (s *swapClientServer) StaticOpenChannel(ctx context.Context, type filterFunc func(deposits *deposit.Deposit) bool -func filter(deposits []*deposit.Deposit, f filterFunc) []*looprpc.Deposit { +func (s *swapClientServer) filterDeposits(deposits []*deposit.Deposit, + f filterFunc) ([]*looprpc.Deposit, error) { + var clientDeposits []*looprpc.Deposit for _, d := range deposits { if !f(d) { continue } - swapHash := make([]byte, 0, len(lntypes.Hash{})) - if d.SwapHash != nil { - swapHash = d.SwapHash[:] - } - - hash := d.Hash - outpoint := wire.NewOutPoint(&hash, d.Index).String() - deposit := &looprpc.Deposit{ - Id: d.ID[:], - State: toClientDepositState( - d.GetState(), - ), - Outpoint: outpoint, - Value: int64(d.Value), - ConfirmationHeight: d.GetConfirmationHeight(), - SwapHash: swapHash, + deposit, err := s.rpcDeposit(d) + if err != nil { + return nil, err } clientDeposits = append(clientDeposits, deposit) } - return clientDeposits + return clientDeposits, nil +} + +func (s *swapClientServer) rpcDeposit(d *deposit.Deposit) ( + *looprpc.Deposit, error) { + + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address parameters for "+ + "deposit %v", d.OutPoint) + } + + swapHash := make([]byte, 0, len(lntypes.Hash{})) + if d.SwapHash != nil { + swapHash = d.SwapHash[:] + } + + hash := d.Hash + outpoint := wire.NewOutPoint(&hash, d.Index).String() + deposit := &looprpc.Deposit{ + Id: d.ID[:], + State: toClientDepositState( + d.GetState(), + ), + Outpoint: outpoint, + Value: int64(d.Value), + ConfirmationHeight: d.GetConfirmationHeight(), + SwapHash: swapHash, + } + + if s.staticAddressManager == nil { + return nil, fmt.Errorf("static address manager not configured") + } + + staticAddress, err := s.staticAddressManager.GetTaprootAddress( + d.AddressParams.ClientPubkey, d.AddressParams.ServerPubkey, + int64(d.AddressParams.Expiry), + ) + if err != nil { + return nil, err + } + deposit.StaticAddress = staticAddress.String() + + return deposit, nil } func toClientDepositState(state fsm.StateType) looprpc.DepositState { diff --git a/loopd/swapclient_server_staticaddr_test.go b/loopd/swapclient_server_staticaddr_test.go index 44b561c3b..3d4ab3f63 100644 --- a/loopd/swapclient_server_staticaddr_test.go +++ b/loopd/swapclient_server_staticaddr_test.go @@ -15,10 +15,13 @@ import ( "github.com/lightninglabs/loop/looprpc" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/loopin" "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/staticaddr/withdraw" mock_lnd "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet" "github.com/stretchr/testify/require" "google.golang.org/grpc" @@ -430,6 +433,17 @@ func TestListStaticAddressDepositsReturnsVisibleDeposits(t *testing.T) { available.SetState(deposit.Deposited) addrMgr, lnd := newTestStaticAddressContext(t) + addresses, err := addrMgr.GetAllAddresses(context.Background()) + require.NoError(t, err) + require.Len(t, addresses, 1) + available.AddressParams = addresses[0] + + expectedAddr, err := addrMgr.GetTaprootAddress( + addresses[0].ClientPubkey, addresses[0].ServerPubkey, + int64(addresses[0].Expiry), + ) + require.NoError(t, err) + server := &swapClientServer{ depositManager: newTestDepositManager(available), staticAddressManager: addrMgr, @@ -445,6 +459,159 @@ func TestListStaticAddressDepositsReturnsVisibleDeposits(t *testing.T) { t, available.OutPoint.String(), resp.FilteredDeposits[0].Outpoint, ) + require.Equal( + t, expectedAddr.String(), + resp.FilteredDeposits[0].StaticAddress, + ) +} + +// TestStaticAddressWithdrawalIncludesDepositAddress verifies withdrawal +// listings use the common deposit conversion path, including the address that +// received each deposit. +func TestStaticAddressWithdrawalIncludesDepositAddress(t *testing.T) { + t.Parallel() + + addrMgr, _ := newTestStaticAddressContext(t) + addresses, err := addrMgr.GetAllAddresses(context.Background()) + require.NoError(t, err) + require.Len(t, addresses, 1) + + expectedAddr, err := addrMgr.GetTaprootAddress( + addresses[0].ClientPubkey, addresses[0].ServerPubkey, + int64(addresses[0].Expiry), + ) + require.NoError(t, err) + + d := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 3, + }, + AddressParams: addresses[0], + } + d.SetState(deposit.Withdrawn) + + server := &swapClientServer{ + staticAddressManager: addrMgr, + } + rpcWithdrawal, err := server.rpcStaticAddressWithdrawal( + withdraw.Withdrawal{ + Deposits: []*deposit.Deposit{d}, + }, + ) + require.NoError(t, err) + require.Len(t, rpcWithdrawal.Deposits, 1) + require.Equal( + t, expectedAddr.String(), + rpcWithdrawal.Deposits[0].StaticAddress, + ) +} + +func TestRPCDepositRequiresAddressParams(t *testing.T) { + t.Parallel() + + d := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{7}, + Index: 7, + }, + } + + server := &swapClientServer{} + rpcDeposit, err := server.rpcDeposit(d) + require.Nil(t, rpcDeposit) + require.ErrorContains( + t, err, "missing static address parameters for deposit "+ + d.OutPoint.String(), + ) +} + +func TestPopulateBlocksUntilExpiryUsesOwningAddress(t *testing.T) { + t.Parallel() + + const confirmationHeight = int64(590) + first := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{8}, + Index: 8, + }, + ConfirmationHeight: confirmationHeight, + AddressParams: &script.Parameters{ + Expiry: 20, + }, + } + second := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{9}, + Index: 9, + }, + ConfirmationHeight: confirmationHeight, + AddressParams: &script.Parameters{ + Expiry: 40, + }, + } + rpcDeposits := []*looprpc.Deposit{ + { + Outpoint: first.OutPoint.String(), + ConfirmationHeight: confirmationHeight, + }, + { + Outpoint: second.OutPoint.String(), + ConfirmationHeight: confirmationHeight, + }, + } + + lnd := mock_lnd.NewMockLnd() + server := &swapClientServer{lnd: &lnd.LndServices} + err := server.populateBlocksUntilExpiry( + t.Context(), []*deposit.Deposit{first, second}, rpcDeposits, + ) + require.NoError(t, err) + require.EqualValues(t, 10, rpcDeposits[0].BlocksUntilExpiry) + require.EqualValues(t, 30, rpcDeposits[1].BlocksUntilExpiry) +} + +func TestStaticAddressLoopInResponseIncludesDepositAddress(t *testing.T) { + t.Parallel() + + addrMgr, lnd := newTestStaticAddressContext(t) + addresses, err := addrMgr.GetAllAddresses(t.Context()) + require.NoError(t, err) + require.Len(t, addresses, 1) + + d := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{10}, + Index: 10, + }, + Value: 100_000, + ConfirmationHeight: 590, + AddressParams: addresses[0], + } + d.SetState(deposit.LoopingIn) + loopIn := &loopin.StaticAddressLoopIn{ + SwapHash: lntypes.Hash{10}, + Deposits: []*deposit.Deposit{d}, + } + + server := &swapClientServer{ + staticAddressManager: addrMgr, + lnd: &lnd.LndServices, + } + resp, err := server.rpcStaticAddressLoopInResponse( + t.Context(), loopIn, + ) + require.NoError(t, err) + require.Len(t, resp.UsedDeposits, 1) + + expectedAddr, err := addrMgr.GetTaprootAddress( + addresses[0].ClientPubkey, addresses[0].ServerPubkey, + int64(addresses[0].Expiry), + ) + require.NoError(t, err) + require.Equal( + t, expectedAddr.String(), resp.UsedDeposits[0].StaticAddress, + ) } // TestGetStaticAddressSummaryTotalsDeposits verifies visible deposits are @@ -505,13 +672,18 @@ func TestGetLoopInQuoteRejectsUnavailableSelectedDeposit(t *testing.T) { locked.SetState(deposit.LoopingIn) addrMgr, lnd := newTestStaticAddressContext(t) + addresses, err := addrMgr.GetAllAddresses(t.Context()) + require.NoError(t, err) + require.Len(t, addresses, 1) + locked.AddressParams = addresses[0] + server := &swapClientServer{ depositManager: newTestDepositManager(locked), staticAddressManager: addrMgr, lnd: &lnd.LndServices, } - _, err := server.GetLoopInQuote(context.Background(), &looprpc.QuoteRequest{ + _, err = server.GetLoopInQuote(context.Background(), &looprpc.QuoteRequest{ DepositOutpoints: []string{locked.OutPoint.String()}, }) require.ErrorContains(t, err, "is not currently available") diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index 7a34c0fb6..286c50430 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -416,6 +416,17 @@ func TestListStaticAddressSwapsPopulatesTimingAndCosts(t *testing.T) { } testDeposit.SetState(deposit.LoopedIn) + _, clientPubkey := mock_lnd.CreateKey(1) + _, serverPubkey := mock_lnd.CreateKey(2) + staticAddressParams := &script.Parameters{ + ID: 1, + ClientPubkey: clientPubkey, + ServerPubkey: serverPubkey, + Expiry: staticAddressExpiry, + PkScript: []byte("pkscript"), + } + testDeposit.AddressParams = staticAddressParams + initiationTime := time.Unix(1_234, 567).UTC() lastUpdateTime := time.Unix(2_345, 678).UTC() staticLoopIn := &loopin.StaticAddressLoopIn{ @@ -446,15 +457,8 @@ func TestListStaticAddressSwapsPopulatesTimingAndCosts(t *testing.T) { }, 1) require.NoError(t, err) - _, clientPubkey := mock_lnd.CreateKey(1) - _, serverPubkey := mock_lnd.CreateKey(2) addrStore := &mockAddressStore{ - params: []*script.Parameters{{ - ClientPubkey: clientPubkey, - ServerPubkey: serverPubkey, - Expiry: staticAddressExpiry, - PkScript: []byte("pkscript"), - }}, + params: []*script.Parameters{staticAddressParams}, } addrMgr, err := address.NewManager(&address.ManagerConfig{ Store: addrStore, @@ -462,6 +466,12 @@ func TestListStaticAddressSwapsPopulatesTimingAndCosts(t *testing.T) { ChainParams: lnd.ChainParams, }, 1) require.NoError(t, err) + expectedStaticAddress, err := addrMgr.GetTaprootAddress( + staticAddressParams.ClientPubkey, + staticAddressParams.ServerPubkey, + int64(staticAddressParams.Expiry), + ) + require.NoError(t, err) server := &swapClientServer{ network: lndclient.NetworkTestnet, @@ -500,6 +510,7 @@ func TestListStaticAddressSwapsPopulatesTimingAndCosts(t *testing.T) { require.Equal(t, depositConfHeight, rpcDeposit.ConfirmationHeight) require.Equal(t, swapHash[:], rpcDeposit.SwapHash) require.Equal(t, looprpc.DepositState_LOOPED_IN, rpcDeposit.State) + require.Equal(t, expectedStaticAddress.String(), rpcDeposit.StaticAddress) require.Equal( t, depositConfHeight+int64(staticAddressExpiry)-600, rpcDeposit.BlocksUntilExpiry, diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index 46f42a6bb..1cd616495 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -5796,7 +5796,9 @@ type Deposit struct { BlocksUntilExpiry int64 `protobuf:"varint,6,opt,name=blocks_until_expiry,json=blocksUntilExpiry,proto3" json:"blocks_until_expiry,omitempty"` // The swap hash of the swap that this deposit is part of. This field is only // set if the deposit is part of a loop-in swap. - SwapHash []byte `protobuf:"bytes,7,opt,name=swap_hash,json=swapHash,proto3" json:"swap_hash,omitempty"` + SwapHash []byte `protobuf:"bytes,7,opt,name=swap_hash,json=swapHash,proto3" json:"swap_hash,omitempty"` + // The static address that the deposit was sent to. + StaticAddress string `protobuf:"bytes,8,opt,name=static_address,json=staticAddress,proto3" json:"static_address,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -5880,6 +5882,13 @@ func (x *Deposit) GetSwapHash() []byte { return nil } +func (x *Deposit) GetStaticAddress() string { + if x != nil { + return x.StaticAddress + } + return "" +} + type StaticAddressWithdrawal struct { state protoimpl.MessageState `protogen:"open.v1"` // The transaction id of the withdrawal transaction. @@ -7256,7 +7265,7 @@ const file_client_proto_rawDesc = "" + "\x18value_looped_in_satoshis\x18\b \x01(\x03R\x15valueLoopedInSatoshis\x12J\n" + "\"value_htlc_timeout_sweeps_satoshis\x18\t \x01(\x03R\x1evalueHtlcTimeoutSweepsSatoshis\x122\n" + "\x15value_channels_opened\x18\n" + - " \x01(\x03R\x13valueChannelsOpened\"\xf6\x01\n" + + " \x01(\x03R\x13valueChannelsOpened\"\x9d\x02\n" + "\aDeposit\x12\x0e\n" + "\x02id\x18\x01 \x01(\fR\x02id\x12+\n" + "\x05state\x18\x02 \x01(\x0e2\x15.looprpc.DepositStateR\x05state\x12\x1a\n" + @@ -7264,7 +7273,8 @@ const file_client_proto_rawDesc = "" + "\x05value\x18\x04 \x01(\x03R\x05value\x12/\n" + "\x13confirmation_height\x18\x05 \x01(\x03R\x12confirmationHeight\x12.\n" + "\x13blocks_until_expiry\x18\x06 \x01(\x03R\x11blocksUntilExpiry\x12\x1b\n" + - "\tswap_hash\x18\a \x01(\fR\bswapHash\"\xc2\x02\n" + + "\tswap_hash\x18\a \x01(\fR\bswapHash\x12%\n" + + "\x0estatic_address\x18\b \x01(\tR\rstaticAddress\"\xc2\x02\n" + "\x17StaticAddressWithdrawal\x12\x13\n" + "\x05tx_id\x18\x01 \x01(\tR\x04txId\x12,\n" + "\bdeposits\x18\x02 \x03(\v2\x10.looprpc.DepositR\bdeposits\x12A\n" + diff --git a/looprpc/client.proto b/looprpc/client.proto index 94fa78703..4af0848b5 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -2119,6 +2119,11 @@ message Deposit { set if the deposit is part of a loop-in swap. */ bytes swap_hash = 7; + + /* + The static address that the deposit was sent to. + */ + string static_address = 8; } message StaticAddressWithdrawal { diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index 2b8851dd8..9c611a29a 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -1708,6 +1708,10 @@ "type": "string", "format": "byte", "description": "The swap hash of the swap that this deposit is part of. This field is only\nset if the deposit is part of a loop-in swap." + }, + "static_address": { + "type": "string", + "description": "The static address that the deposit was sent to." } } }, From d351341968150e8e3d478de004453e664b2b5449 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 26 Aug 2026 15:54:41 +0200 Subject: [PATCH 16/30] staticaddr: classify missing address RPC errors The CLI previously recognized an uninitialized static-address seed by searching arbitrary gRPC error text. Any wrapping or wording change could suppress the L402 backup warning before a user funded a newly derived address. Map ErrNoStaticAddress to codes.NotFound at the RPC boundary and classify that status in the CLI. Retain compatibility with older daemons only for an exact Unknown-status message, avoiding the broad substring match, and cover both sides with regression tests. --- cmd/loop/staticaddr.go | 22 ++++++++- cmd/loop/staticaddr_test.go | 52 ++++++++++++++++++++++ loopd/swapclient_server.go | 10 ++++- loopd/swapclient_server_staticaddr_test.go | 28 ++++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index aef19f411..933df234b 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -22,6 +22,8 @@ import ( "github.com/lightningnetwork/lnd/routing/route" "github.com/urfave/cli/v3" "golang.org/x/term" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) func init() { @@ -1152,7 +1154,7 @@ func maybeDisplayNewAddressWarning(ctx context.Context, case err == nil: return nil - case strings.Contains(err.Error(), address.ErrNoStaticAddress.Error()): + case isNoStaticAddressSummaryError(err): return displayNewAddressWarningTo(input, output, force) default: @@ -1160,6 +1162,24 @@ func maybeDisplayNewAddressWarning(ctx context.Context, } } +// isNoStaticAddressSummaryError reports whether loopd has not initialized the +// static address seed yet. New loopd versions return NotFound. The exact +// Unknown status is retained for compatibility with older loopd versions that +// returned ErrNoStaticAddress directly across the gRPC boundary. +func isNoStaticAddressSummaryError(err error) bool { + switch status.Code(err) { + case codes.NotFound: + return true + + case codes.Unknown: + return status.Convert(err).Message() == + address.ErrNoStaticAddress.Error() + + default: + return false + } +} + func displayNewAddressWarning() error { return displayNewAddressWarningTo(os.Stdin, os.Stdout, false) } diff --git a/cmd/loop/staticaddr_test.go b/cmd/loop/staticaddr_test.go index e99d580d9..b1aef2e73 100644 --- a/cmd/loop/staticaddr_test.go +++ b/cmd/loop/staticaddr_test.go @@ -18,6 +18,8 @@ import ( "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) type staticAddressSummaryErrorClient struct { @@ -121,6 +123,56 @@ func TestStaticAddressDepositForceFirstUseNonInteractive(t *testing.T) { require.NotContains(t, output.String(), "CONTINUE WITH NEW ADDRESS") } +// TestIsNoStaticAddressSummaryError verifies that the CLI recognizes the +// durable status returned by current loopd versions while keeping only the +// exact legacy Unknown status for compatibility with older versions. +func TestIsNoStaticAddressSummaryError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + expected bool + }{ + { + name: "not found", + err: status.Error(codes.NotFound, "not initialized"), + expected: true, + }, + { + name: "legacy unknown", + err: status.Error( + codes.Unknown, address.ErrNoStaticAddress.Error(), + ), + expected: true, + }, + { + name: "wrapped legacy message", + err: status.Error( + codes.Unknown, "lookup failed: "+ + address.ErrNoStaticAddress.Error(), + ), + }, + { + name: "wrong status", + err: status.Error( + codes.Internal, address.ErrNoStaticAddress.Error(), + ), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + require.Equal( + t, test.expected, + isNoStaticAddressSummaryError(test.err), + ) + }) + } +} + func TestStaticAddressDepositRequestAllowsNoUtxos(t *testing.T) { t.Parallel() diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 5c20f989e..441485e52 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -2612,11 +2612,17 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context, } params, err := s.staticAddressManager.GetStaticAddressParameters(ctx) + + if errors.Is(err, address.ErrNoStaticAddress) { + return nil, status.Error( + codes.NotFound, address.ErrNoStaticAddress.Error(), + ) + } if err != nil { return nil, err } - address, err := s.staticAddressManager.GetTaprootAddress( + staticAddress, err := s.staticAddressManager.GetTaprootAddress( params.ClientPubkey, params.ServerPubkey, int64(params.Expiry), ) if err != nil { @@ -2624,7 +2630,7 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context, } return &looprpc.StaticAddressSummaryResponse{ - StaticAddress: address.String(), + StaticAddress: staticAddress.String(), RelativeExpiryBlocks: uint64(params.Expiry), TotalNumDeposits: uint32(totalNumDeposits), ValueUnconfirmedSatoshis: valueUnconfirmed, diff --git a/loopd/swapclient_server_staticaddr_test.go b/loopd/swapclient_server_staticaddr_test.go index 3d4ab3f63..d22d1c9c1 100644 --- a/loopd/swapclient_server_staticaddr_test.go +++ b/loopd/swapclient_server_staticaddr_test.go @@ -25,6 +25,8 @@ import ( "github.com/lightningnetwork/lnd/lnwallet" "github.com/stretchr/testify/require" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" ) @@ -656,6 +658,32 @@ func TestGetStaticAddressSummaryTotalsDeposits(t *testing.T) { require.EqualValues(t, 3_000, resp.ValueDepositedSatoshis) } +// TestGetStaticAddressSummaryNoAddress verifies a missing static address seed +// is exposed as a durable gRPC status instead of an application error encoded +// in an Unknown status. +func TestGetStaticAddressSummaryNoAddress(t *testing.T) { + t.Parallel() + + addrMgr, err := address.NewManager(&address.ManagerConfig{ + Store: &mockAddressStore{}, + }, 1) + require.NoError(t, err) + + server := &swapClientServer{ + depositManager: newTestDepositManager(), + staticAddressManager: addrMgr, + } + + _, err = server.GetStaticAddressSummary( + context.Background(), &looprpc.StaticAddressSummaryRequest{}, + ) + require.Equal(t, codes.NotFound, status.Code(err)) + require.Equal( + t, address.ErrNoStaticAddress.Error(), + status.Convert(err).Message(), + ) +} + // TestGetLoopInQuoteRejectsUnavailableSelectedDeposit verifies manual quote // requests fail for selected deposits that are no longer available. func TestGetLoopInQuoteRejectsUnavailableSelectedDeposit(t *testing.T) { From 7d6b196bf06831583a152cfaa8879218cd377efc Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 26 Aug 2026 15:55:01 +0200 Subject: [PATCH 17/30] looprpc: deprecate singular static summary address A static-address account can now receive deposits across multiple derived addresses, so the singular summary field can no longer describe the current receive address. Removing or repurposing field 1 would break existing clients. Keep the wire value as the legacy/root derivation address, formally deprecate it, document the expiry as the shared CSV delay, and direct CLI users to derive a fresh receive address. Rename the server locals to make the compatibility behavior explicit and regenerate protobuf and Swagger artifacts. --- cmd/loop/staticaddr.go | 6 ++++-- docs/loop.md | 2 +- loopd/swapclient_server.go | 12 +++++++----- looprpc/client.pb.go | 16 +++++++++++----- looprpc/client.proto | 9 ++++++--- looprpc/client.swagger.json | 4 ++-- 6 files changed, 31 insertions(+), 18 deletions(-) diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index 933df234b..83e8f7090 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -683,8 +683,10 @@ var summaryCommand = &cli.Command{ Aliases: []string{"s"}, Usage: "Display a summary of static address related information.", Description: ` - Displays various static address related information about deposits, - withdrawals, swaps and channel openings. + Displays various static address related information about deposits, + withdrawals, swaps and channel openings. The deprecated static_address field + is the legacy/root address retained for compatibility, not the current + receive address. Use "loop static new" to derive a new receive address. `, Action: summary, } diff --git a/docs/loop.md b/docs/loop.md index 0ec93b58f..63ed338a8 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -678,7 +678,7 @@ The following flags are supported: Display a summary of static address related information. -Displays various static address related information about deposits, withdrawals, swaps and channel openings. +Displays various static address related information about deposits, withdrawals, swaps and channel openings. The deprecated static_address field is the legacy/root address retained for compatibility, not the current receive address. Use "loop static new" to derive a new receive address. Usage: diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 441485e52..c2f83cf17 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -2611,7 +2611,8 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context, } } - params, err := s.staticAddressManager.GetStaticAddressParameters(ctx) + legacyParams, err := + s.staticAddressManager.GetStaticAddressParameters(ctx) if errors.Is(err, address.ErrNoStaticAddress) { return nil, status.Error( @@ -2622,16 +2623,17 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context, return nil, err } - staticAddress, err := s.staticAddressManager.GetTaprootAddress( - params.ClientPubkey, params.ServerPubkey, int64(params.Expiry), + legacyAddress, err := s.staticAddressManager.GetTaprootAddress( + legacyParams.ClientPubkey, legacyParams.ServerPubkey, + int64(legacyParams.Expiry), ) if err != nil { return nil, err } return &looprpc.StaticAddressSummaryResponse{ - StaticAddress: staticAddress.String(), - RelativeExpiryBlocks: uint64(params.Expiry), + StaticAddress: legacyAddress.String(), //nolint:staticcheck + RelativeExpiryBlocks: uint64(legacyParams.Expiry), TotalNumDeposits: uint32(totalNumDeposits), ValueUnconfirmedSatoshis: valueUnconfirmed, ValueDepositedSatoshis: valueDeposited, diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index 1cd616495..c2cde1d3a 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -5655,9 +5655,14 @@ func (*StaticAddressSummaryRequest) Descriptor() ([]byte, []int) { type StaticAddressSummaryResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - // The static address of the client. + // Deprecated: The legacy/root static address used as the derivation seed. + // New deposits should use fresh addresses returned by NewStaticAddress; this + // address must not be treated as the current receive address. + // + // Deprecated: Marked as deprecated in client.proto. StaticAddress string `protobuf:"bytes,1,opt,name=static_address,json=staticAddress,proto3" json:"static_address,omitempty"` - // The CSV expiry of the static address. + // The shared CSV delay in blocks inherited by all static addresses derived + // from the legacy/root seed. RelativeExpiryBlocks uint64 `protobuf:"varint,2,opt,name=relative_expiry_blocks,json=relativeExpiryBlocks,proto3" json:"relative_expiry_blocks,omitempty"` // The total number of deposits. TotalNumDeposits uint32 `protobuf:"varint,3,opt,name=total_num_deposits,json=totalNumDeposits,proto3" json:"total_num_deposits,omitempty"` @@ -5709,6 +5714,7 @@ func (*StaticAddressSummaryResponse) Descriptor() ([]byte, []int) { return file_client_proto_rawDescGZIP(), []int{69} } +// Deprecated: Marked as deprecated in client.proto. func (x *StaticAddressSummaryResponse) GetStaticAddress() string { if x != nil { return x.StaticAddress @@ -7253,9 +7259,9 @@ const file_client_proto_rawDesc = "" + "\x1dListStaticAddressSwapsRequest\"X\n" + "\x1eListStaticAddressSwapsResponse\x126\n" + "\x05swaps\x18\x01 \x03(\v2 .looprpc.StaticAddressLoopInSwapR\x05swaps\"\x1d\n" + - "\x1bStaticAddressSummaryRequest\"\xca\x04\n" + - "\x1cStaticAddressSummaryResponse\x12%\n" + - "\x0estatic_address\x18\x01 \x01(\tR\rstaticAddress\x124\n" + + "\x1bStaticAddressSummaryRequest\"\xce\x04\n" + + "\x1cStaticAddressSummaryResponse\x12)\n" + + "\x0estatic_address\x18\x01 \x01(\tB\x02\x18\x01R\rstaticAddress\x124\n" + "\x16relative_expiry_blocks\x18\x02 \x01(\x04R\x14relativeExpiryBlocks\x12,\n" + "\x12total_num_deposits\x18\x03 \x01(\rR\x10totalNumDeposits\x12<\n" + "\x1avalue_unconfirmed_satoshis\x18\x04 \x01(\x03R\x18valueUnconfirmedSatoshis\x128\n" + diff --git a/looprpc/client.proto b/looprpc/client.proto index 4af0848b5..ca9dbee57 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -1952,12 +1952,15 @@ message StaticAddressSummaryRequest { message StaticAddressSummaryResponse { /* - The static address of the client. + Deprecated: The legacy/root static address used as the derivation seed. + New deposits should use fresh addresses returned by NewStaticAddress; this + address must not be treated as the current receive address. */ - string static_address = 1; + string static_address = 1 [deprecated = true]; /* - The CSV expiry of the static address. + The shared CSV delay in blocks inherited by all static addresses derived + from the legacy/root seed. */ uint64 relative_expiry_blocks = 2; diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index 9c611a29a..98343aeb3 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -2996,12 +2996,12 @@ "properties": { "static_address": { "type": "string", - "description": "The static address of the client." + "description": "Deprecated: The legacy/root static address used as the derivation seed.\nNew deposits should use fresh addresses returned by NewStaticAddress; this\naddress must not be treated as the current receive address." }, "relative_expiry_blocks": { "type": "string", "format": "uint64", - "description": "The CSV expiry of the static address." + "description": "The shared CSV delay in blocks inherited by all static addresses derived\nfrom the legacy/root seed." }, "total_num_deposits": { "type": "integer", From 72e513df2f2ed1c3b50bcdcdf9994bd5105471b7 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 26 Aug 2026 18:14:34 +0200 Subject: [PATCH 18/30] staticaddr/deposit: bind expiry confirmation to outpoint --- staticaddr/deposit/actions.go | 107 ++++++++++++++++++-- staticaddr/deposit/actions_test.go | 155 +++++++++++++++++++++++++++-- staticaddr/deposit/manager_test.go | 17 +++- 3 files changed, 260 insertions(+), 19 deletions(-) diff --git a/staticaddr/deposit/actions.go b/staticaddr/deposit/actions.go index 03a65e00b..6ab9cb1d2 100644 --- a/staticaddr/deposit/actions.go +++ b/staticaddr/deposit/actions.go @@ -1,6 +1,7 @@ package deposit import ( + "bytes" "context" "errors" "fmt" @@ -131,10 +132,16 @@ func (f *FSM) PublishDepositExpirySweepAction(ctx context.Context, func (f *FSM) WaitForExpirySweepAction(ctx context.Context, _ fsm.EventContext) fsm.EventType { - // Register by script only so an RBF replacement of the timeout sweep is - // still detected after restart with a stale ExpirySweepTxid. - spendChan, errSpendChan, err := f.cfg.ChainNotifier.RegisterConfirmationsNtfn( //nolint:lll - ctx, nil, f.deposit.TimeOutSweepPkScript, DefaultConfTarget, + if f.deposit.AddressParams == nil { + return f.HandleError(fmt.Errorf("missing static address " + + "parameters")) + } + + // Watch the deposit outpoint instead of the sweep destination script. + // This follows RBF replacements while ensuring that an unrelated + // transaction paying the same destination cannot finalize the deposit. + spendChan, spendErrChan, err := f.cfg.ChainNotifier.RegisterSpendNtfn( + ctx, &f.deposit.OutPoint, f.deposit.AddressParams.PkScript, int32(f.deposit.GetConfirmationHeight()), ) if err != nil { @@ -142,19 +149,103 @@ func (f *FSM) WaitForExpirySweepAction(ctx context.Context, } select { - case err = <-errSpendChan: + case err = <-spendErrChan: log.Debugf("error while sweeping expired deposit: %v", err) return fsm.OnError - case confirmedTx := <-spendChan: - f.deposit.ExpirySweepTxid = confirmedTx.Tx.TxHash() - return OnExpirySwept + case spend, ok := <-spendChan: + if !ok || spend == nil || spend.SpendingTx == nil { + return f.HandleError(errors.New("expiry spend notification " + + "missing transaction")) + } + + spendingTx := spend.SpendingTx + if err := validateExpirySpend( + spendingTx, f.deposit.OutPoint, + f.deposit.TimeOutSweepPkScript, + ); err != nil { + return f.HandleError(err) + } + + spendingTxID := spendingTx.TxHash() + heightHint := spend.SpendingHeight + if heightHint <= 0 { + heightHint = int32(f.deposit.GetConfirmationHeight()) + } + + confChan, confErrChan, err := + f.cfg.ChainNotifier.RegisterConfirmationsNtfn( + ctx, &spendingTxID, + f.deposit.TimeOutSweepPkScript, + DefaultConfTarget, heightHint, + ) + if err != nil { + return f.HandleError(err) + } + + select { + case err = <-confErrChan: + log.Debugf("error while confirming expired deposit: %v", + err) + return fsm.OnError + + case confirmation, ok := <-confChan: + if !ok || confirmation == nil || confirmation.Tx == nil { + return f.HandleError(errors.New("expiry confirmation " + + "missing transaction")) + } + confirmedTx := confirmation.Tx + + if confirmedTx.TxHash() != spendingTxID { + return f.HandleError(fmt.Errorf("expiry confirmation " + + "transaction does not match outpoint spender")) + } + if err := validateExpirySpend( + confirmedTx, f.deposit.OutPoint, + f.deposit.TimeOutSweepPkScript, + ); err != nil { + return f.HandleError(err) + } + + f.deposit.ExpirySweepTxid = spendingTxID + return OnExpirySwept + + case <-ctx.Done(): + return fsm.OnError + } case <-ctx.Done(): return fsm.OnError } } +// validateExpirySpend verifies that tx spends the deposit outpoint to the +// configured timeout sweep destination. +func validateExpirySpend(tx *wire.MsgTx, outpoint wire.OutPoint, + timeoutPkScript []byte) error { + + spendsDeposit := false + for _, txIn := range tx.TxIn { + if txIn.PreviousOutPoint == outpoint { + spendsDeposit = true + break + } + } + if !spendsDeposit { + return fmt.Errorf("expiry transaction does not spend deposit %v", + outpoint) + } + + for _, txOut := range tx.TxOut { + if bytes.Equal(txOut.PkScript, timeoutPkScript) { + return nil + } + } + + return errors.New("expiry transaction does not pay timeout sweep " + + "destination") +} + // FinalizeDepositAction is the final action after a withdrawal. It signals to // the manager that the deposit has been swept and the FSM can be removed. func (f *FSM) FinalizeDepositAction(_ context.Context, diff --git a/staticaddr/deposit/actions_test.go b/staticaddr/deposit/actions_test.go index 15a913999..fb2128163 100644 --- a/staticaddr/deposit/actions_test.go +++ b/staticaddr/deposit/actions_test.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -54,47 +55,181 @@ func TestFinalizeDepositActionDoesNotBlock(t *testing.T) { } } -func TestWaitForExpirySweepActionRegistersByScriptOnly(t *testing.T) { +func TestWaitForExpirySweepActionTracksOutpointSpender(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() + depositOutpoint := wire.OutPoint{Hash: chainhash.Hash{1}, Index: 2} + depositPkScript := []byte{0x51, 0x20, 0x00} timeoutPkScript := []byte{0x51, 0x20, 0x01} + spendChan := make(chan *chainntnfs.SpendDetail, 1) + spendErrChan := make(chan error, 1) confChan := make(chan *chainntnfs.TxConfirmation, 1) - errChan := make(chan error, 1) + confErrChan := make(chan error, 1) chainNotifier := &MockChainNotifier{} + chainNotifier.On( + "RegisterSpendNtfn", + mock.Anything, + mock.MatchedBy(func(outpoint *wire.OutPoint) bool { + return outpoint != nil && *outpoint == depositOutpoint + }), + depositPkScript, + int32(42), + ).Return(spendChan, spendErrChan, nil).Once() + + spendingTx := wire.NewMsgTx(2) + spendingTx.AddTxIn(&wire.TxIn{PreviousOutPoint: depositOutpoint}) + spendingTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: timeoutPkScript, + }) + spendingTxID := spendingTx.TxHash() + chainNotifier.On( "RegisterConfirmationsNtfn", mock.Anything, mock.MatchedBy(func(txid *chainhash.Hash) bool { - return txid == nil + return txid != nil && *txid == spendingTxID }), timeoutPkScript, int32(DefaultConfTarget), - int32(42), - ).Return(confChan, errChan, nil).Once() + int32(50), + ).Return(confChan, confErrChan, nil).Once() depositFSM := &FSM{ + StateMachine: &fsm.StateMachine{}, cfg: &ManagerConfig{ ChainNotifier: chainNotifier, }, deposit: &Deposit{ + OutPoint: depositOutpoint, ConfirmationHeight: 42, ExpirySweepTxid: chainhash.Hash{9}, TimeOutSweepPkScript: timeoutPkScript, + AddressParams: &address.Parameters{ + PkScript: depositPkScript, + }, + }, + } + + spendChan <- &chainntnfs.SpendDetail{ + SpendingTx: spendingTx, + SpendingHeight: 50, + } + confChan <- &chainntnfs.TxConfirmation{Tx: spendingTx} + + event := depositFSM.WaitForExpirySweepAction(ctx, nil) + require.Equal(t, OnExpirySwept, event) + require.Equal(t, spendingTxID, depositFSM.deposit.ExpirySweepTxid) + chainNotifier.AssertExpectations(t) +} + +func TestWaitForExpirySweepActionRejectsInvalidSpend(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + depositOutpoint := wire.OutPoint{Hash: chainhash.Hash{1}, Index: 2} + depositPkScript := []byte{0x51, 0x20, 0x00} + timeoutPkScript := []byte{0x51, 0x20, 0x01} + spendChan := make(chan *chainntnfs.SpendDetail, 1) + spendErrChan := make(chan error, 1) + + chainNotifier := &MockChainNotifier{} + chainNotifier.On( + "RegisterSpendNtfn", mock.Anything, mock.Anything, + depositPkScript, int32(42), + ).Return(spendChan, spendErrChan, nil).Once() + + depositFSM := &FSM{ + StateMachine: &fsm.StateMachine{}, + cfg: &ManagerConfig{ + ChainNotifier: chainNotifier, + }, + deposit: &Deposit{ + OutPoint: depositOutpoint, + ConfirmationHeight: 42, + TimeOutSweepPkScript: timeoutPkScript, + AddressParams: &address.Parameters{ + PkScript: depositPkScript, + }, }, } - confirmedTx := wire.NewMsgTx(2) - confirmedTx.AddTxOut(&wire.TxOut{ + // A transaction that merely pays the timeout script must not be treated + // as the deposit's expiry sweep. + unrelatedTx := wire.NewMsgTx(2) + unrelatedTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{2}}, + }) + unrelatedTx.AddTxOut(&wire.TxOut{ Value: 1000, PkScript: timeoutPkScript, }) - confChan <- &chainntnfs.TxConfirmation{Tx: confirmedTx} + spendChan <- &chainntnfs.SpendDetail{SpendingTx: unrelatedTx} event := depositFSM.WaitForExpirySweepAction(ctx, nil) - require.Equal(t, OnExpirySwept, event) - require.Equal(t, confirmedTx.TxHash(), depositFSM.deposit.ExpirySweepTxid) + require.Equal(t, fsm.OnError, event) + require.Zero(t, depositFSM.deposit.ExpirySweepTxid) + chainNotifier.AssertNotCalled(t, "RegisterConfirmationsNtfn") + chainNotifier.AssertExpectations(t) +} + +func TestWaitForExpirySweepActionRejectsMissingConfirmation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + depositOutpoint := wire.OutPoint{Hash: chainhash.Hash{1}, Index: 2} + depositPkScript := []byte{0x51, 0x20, 0x00} + timeoutPkScript := []byte{0x51, 0x20, 0x01} + spendingTx := wire.NewMsgTx(2) + spendingTx.AddTxIn(&wire.TxIn{PreviousOutPoint: depositOutpoint}) + spendingTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: timeoutPkScript, + }) + + spendChan := make(chan *chainntnfs.SpendDetail, 1) + spendErrChan := make(chan error, 1) + confChan := make(chan *chainntnfs.TxConfirmation, 1) + confErrChan := make(chan error, 1) + spendChan <- &chainntnfs.SpendDetail{ + SpendingTx: spendingTx, + SpendingHeight: 50, + } + confChan <- nil + + chainNotifier := &MockChainNotifier{} + chainNotifier.On( + "RegisterSpendNtfn", mock.Anything, mock.Anything, + depositPkScript, int32(42), + ).Return(spendChan, spendErrChan, nil).Once() + chainNotifier.On( + "RegisterConfirmationsNtfn", mock.Anything, mock.Anything, + timeoutPkScript, int32(DefaultConfTarget), int32(50), + ).Return(confChan, confErrChan, nil).Once() + + depositFSM := &FSM{ + StateMachine: &fsm.StateMachine{}, + cfg: &ManagerConfig{ + ChainNotifier: chainNotifier, + }, + deposit: &Deposit{ + OutPoint: depositOutpoint, + ConfirmationHeight: 42, + TimeOutSweepPkScript: timeoutPkScript, + AddressParams: &address.Parameters{ + PkScript: depositPkScript, + }, + }, + } + + event := depositFSM.WaitForExpirySweepAction(ctx, nil) + require.Equal(t, fsm.OnError, event) + require.ErrorContains( + t, depositFSM.LastActionError, "confirmation missing transaction", + ) + require.Zero(t, depositFSM.deposit.ExpirySweepTxid) chainNotifier.AssertExpectations(t) } diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 9cf2a06bf..2dff5f7cd 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -245,7 +245,7 @@ func (m *MockChainNotifier) RegisterSpendNtfn(ctx context.Context, _ ...lndclient.NotifierOption) (chan *chainntnfs.SpendDetail, chan error, error) { - args := m.Called(ctx, pkScript, heightHint) + args := m.Called(ctx, outpoint, pkScript, heightHint) return args.Get(0).(chan *chainntnfs.SpendDetail), args.Get(1).(chan error), args.Error(2) } @@ -318,6 +318,11 @@ func TestManager(t *testing.T) { } // Ensure that the deposit is waiting for a confirmation notification. + testContext.spendChan <- &chainntnfs.SpendDetail{ + SpentOutPoint: &expiryTx.TxIn[0].PreviousOutPoint, + SpendingTx: expiryTx, + SpendingHeight: int32(defaultDepositConfirmations + defaultExpiry), + } testContext.confChan <- &chainntnfs.TxConfirmation{ BlockHeight: defaultDepositConfirmations + defaultExpiry + 3, Tx: expiryTx, @@ -530,6 +535,8 @@ type ManagerTestContext struct { mockLnd *test.LndMockServices mockStaticAddressClient *mockStaticAddressClient mockAddressManager *mockAddressManager + spendChan chan *chainntnfs.SpendDetail + spendErrChan chan error confChan chan *chainntnfs.TxConfirmation confErrChan chan error blockChan chan int32 @@ -578,6 +585,8 @@ func newManagerTestContextWithStoredDeposits(t *testing.T, mockAddressManager := new(mockAddressManager) mockStore := new(mockStore) mockChainNotifier := new(MockChainNotifier) + spendChan := make(chan *chainntnfs.SpendDetail) + spendErrChan := make(chan error) confChan := make(chan *chainntnfs.TxConfirmation) confErrChan := make(chan error) blockChan := make(chan int32) @@ -630,6 +639,10 @@ func newManagerTestContextWithStoredDeposits(t *testing.T, "RegisterConfirmationsNtfn", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, ).Return(confChan, confErrChan, nil) + mockChainNotifier.On( + "RegisterSpendNtfn", mock.Anything, mock.Anything, + mock.Anything, mock.Anything, + ).Return(spendChan, spendErrChan, nil) mockChainNotifier.On("RegisterBlockEpochNtfn", mock.Anything).Return( blockChan, blockErrChan, nil, @@ -651,6 +664,8 @@ func newManagerTestContextWithStoredDeposits(t *testing.T, mockLnd: mockLnd, mockStaticAddressClient: mockStaticAddressClient, mockAddressManager: mockAddressManager, + spendChan: spendChan, + spendErrChan: spendErrChan, confChan: confChan, confErrChan: confErrChan, blockChan: blockChan, From 567aa8a6a44413f095cd7d0066e3025f5149ea35 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 27 Aug 2026 10:51:36 +0200 Subject: [PATCH 19/30] staticaddr: add multi-address integration coverage Cover per-deposit address ownership and operation-specific change outputs across the shared SQL persistence boundary. Reconstruct the deposit, loop-in, and withdrawal stores to verify ownership and change metadata survive restart. --- staticaddr/loopin/actions_test.go | 41 +++- staticaddr/multi_address_integration_test.go | 238 +++++++++++++++++++ 2 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 staticaddr/multi_address_integration_test.go diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 4dfe9d9b9..3844d351a 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -1044,13 +1044,15 @@ 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) { +// TestInitHtlcActionSendsMultiAddressChangeOutput asserts that fractional +// loop-ins preserve each input's owning address and send an operation-specific +// static change output to the server. +func TestInitHtlcActionSendsMultiAddressChangeOutput(t *testing.T) { t.Parallel() mockLnd := test.NewMockLnd() _, depositClientPubkey := test.CreateKey(31) + _, secondDepositClientPubkey := test.CreateKey(34) _, changeClientPubkey := test.CreateKey(32) _, serverKey := test.CreateKey(33) @@ -1071,6 +1073,17 @@ func TestInitHtlcActionSendsChangeOutput(t *testing.T) { PkScript: []byte{0x51, 0x20, 0x02}, }, } + secondDep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{4}, + Index: 1, + }, + Value: 200_000, + AddressParams: &address.Parameters{ + ClientPubkey: secondDepositClientPubkey, + PkScript: []byte{0x51, 0x20, 0x03}, + }, + } changeParams := &address.Parameters{ ID: 1, ClientPubkey: changeClientPubkey, @@ -1078,9 +1091,9 @@ func TestInitHtlcActionSendsChangeOutput(t *testing.T) { } loopIn := &StaticAddressLoopIn{ - Deposits: []*deposit.Deposit{dep}, - DepositOutpoints: []string{dep.OutPoint.String()}, - SelectedAmount: 300_000, + Deposits: []*deposit.Deposit{dep, secondDep}, + DepositOutpoints: []string{dep.String(), secondDep.String()}, + SelectedAmount: 500_000, QuotedSwapFee: 1_000, InitiationHeight: uint32(mockLnd.Height), InitiationTime: time.Now(), @@ -1109,6 +1122,22 @@ func TestInitHtlcActionSendsChangeOutput(t *testing.T) { require.Nil(t, f.LastActionError) require.NotNil(t, server.request.ChangeOutput) require.EqualValues(t, 200_000, server.request.ChangeOutput.Amount) + require.Equal( + t, depositClientPubkey.SerializeCompressed(), + server.request.DepositToClientPubkeys[dep.String()].GetPubkey(), + ) + require.Equal( + t, dep.AddressParams.PkScript, + server.request.DepositToClientPubkeys[dep.String()].GetPkScript(), + ) + require.Equal( + t, secondDepositClientPubkey.SerializeCompressed(), + server.request.DepositToClientPubkeys[secondDep.String()].GetPubkey(), + ) + require.Equal( + t, secondDep.AddressParams.PkScript, + server.request.DepositToClientPubkeys[secondDep.String()].GetPkScript(), + ) require.Equal( t, changeClientPubkey.SerializeCompressed(), server.request.ChangeOutput.StaticAddress.GetPubkey(), diff --git a/staticaddr/multi_address_integration_test.go b/staticaddr/multi_address_integration_test.go new file mode 100644 index 000000000..2d3332eb7 --- /dev/null +++ b/staticaddr/multi_address_integration_test.go @@ -0,0 +1,238 @@ +package staticaddr_test + +import ( + "context" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/loopin" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/staticaddr/withdraw" + "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/stretchr/testify/require" +) + +// TestMultiAddressPersistenceRecovery exercises the shared SQL boundary used +// by address issuance, deposits, fractional loop-ins and withdrawals. It then +// reconstructs every store to model a loopd restart and verifies that each +// deposit retains its owning address while change uses a separate address. +func TestMultiAddressPersistenceRecovery(t *testing.T) { + ctx := t.Context() + db := loopdb.NewTestDB(t) + t.Cleanup(func() { + db.Close() + }) + + addressStore := address.NewSqlStore(db.BaseDB) + receiveA := createIntegrationAddress( + t, ctx, addressStore, 1, 11, 1, + ) + receiveB := createIntegrationAddress( + t, ctx, addressStore, 2, 12, 2, + ) + loopInChange := createIntegrationAddress( + t, ctx, addressStore, 3, 13, 3, + ) + withdrawChange := createIntegrationAddress( + t, ctx, addressStore, 4, 14, 4, + ) + require.NotEqual(t, loopInChange.ID, withdrawChange.ID) + require.NotEqual(t, loopInChange.PkScript, withdrawChange.PkScript) + + depositStore := deposit.NewSqlStore(db.BaseDB) + loopInA := createIntegrationDeposit( + t, ctx, depositStore, 1, 300_000, receiveA, + ) + loopInB := createIntegrationDeposit( + t, ctx, depositStore, 2, 250_000, receiveB, + ) + withdrawA := createIntegrationDeposit( + t, ctx, depositStore, 3, 200_000, receiveA, + ) + withdrawB := createIntegrationDeposit( + t, ctx, depositStore, 4, 300_000, receiveB, + ) + + _, htlcClientKey := test.CreateKey(21) + _, htlcServerKey := test.CreateKey(22) + timeoutAddr, err := btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + + swapHash := lntypes.Hash{1, 2, 3} + swap := &loopin.StaticAddressLoopIn{ + SwapHash: swapHash, + SwapPreimage: lntypes.Preimage{1, 2, 3}, + DepositOutpoints: []string{loopInA.String(), loopInB.String()}, + Deposits: []*deposit.Deposit{loopInA, loopInB}, + SelectedAmount: 400_000, + ChangeAddressParams: loopInChange, + ClientPubkey: htlcClientKey, + ServerPubkey: htlcServerKey, + HtlcKeyLocator: keychain.KeyLocator{ + Family: 44, + Index: 1, + }, + HtlcTimeoutSweepAddress: timeoutAddr, + InitiationHeight: 100, + InitiationTime: time.Unix(1_700_000_000, 0), + } + swap.SetState(loopin.SignHtlcTx) + loopInStore := loopin.NewSqlStore( + loopdb.NewTypedStore[loopin.Querier](db), + clock.NewTestClock(time.Unix(1_700_000_001, 0)), + &chaincfg.RegressionNetParams, + ) + require.NoError(t, loopInStore.CreateLoopIn(ctx, swap)) + + withdrawStore := withdraw.NewSqlStore( + loopdb.NewTypedStore[withdraw.Querier](db), depositStore, + ) + withdrawDeposits := []*deposit.Deposit{withdrawA, withdrawB} + require.NoError( + t, withdrawStore.CreateWithdrawal(ctx, withdrawDeposits), + ) + + replacementTx := wire.NewMsgTx(2) + replacementTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: withdrawA.OutPoint, + }) + replacementTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: withdrawB.OutPoint, + }) + replacementTx.AddTxOut(&wire.TxOut{ + Value: 425_000, + PkScript: []byte{0x51}, + }) + replacementTx.AddTxOut(&wire.TxOut{ + Value: 50_000, + PkScript: withdrawChange.PkScript, + }) + require.NoError(t, withdrawStore.UpdateWithdrawal( + ctx, withdrawDeposits, replacementTx, 110, + withdrawChange.PkScript, + )) + + // Recreate the stores to exercise the same read path used after restart. + restartedDepositStore := deposit.NewSqlStore(db.BaseDB) + restartedLoopInStore := loopin.NewSqlStore( + loopdb.NewTypedStore[loopin.Querier](db), + clock.NewTestClock(time.Unix(1_700_000_002, 0)), + &chaincfg.RegressionNetParams, + ) + recoveredSwap, err := restartedLoopInStore.GetLoopInByHash(ctx, swapHash) + require.NoError(t, err) + require.Equal(t, btcutil.Amount(400_000), recoveredSwap.SelectedAmount) + require.NotNil(t, recoveredSwap.ChangeAddressParams) + require.Equal( + t, loopInChange.ID, recoveredSwap.ChangeAddressParams.ID, + ) + requireDepositAddressIDs(t, recoveredSwap.Deposits, map[string]int32{ + loopInA.String(): receiveA.ID, + loopInB.String(): receiveB.ID, + }) + + restartedWithdrawStore := withdraw.NewSqlStore( + loopdb.NewTypedStore[withdraw.Querier](db), + restartedDepositStore, + ) + recoveredWithdrawals, err := restartedWithdrawStore.GetAllWithdrawals(ctx) + require.NoError(t, err) + require.Len(t, recoveredWithdrawals, 1) + require.Equal( + t, replacementTx.TxHash(), recoveredWithdrawals[0].TxID, + ) + require.Equal( + t, btcutil.Amount(50_000), recoveredWithdrawals[0].ChangeAmount, + ) + requireDepositAddressIDs( + t, recoveredWithdrawals[0].Deposits, map[string]int32{ + withdrawA.String(): receiveA.ID, + withdrawB.String(): receiveB.ID, + }, + ) +} + +func createIntegrationAddress(t *testing.T, ctx context.Context, + store *address.SqlStore, clientIndex, serverIndex byte, + keyIndex uint32) *address.Parameters { + + t.Helper() + + _, clientKey := test.CreateKey(int32(clientIndex)) + _, serverKey := test.CreateKey(int32(serverIndex)) + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, 1_000, clientKey, serverKey, + ) + require.NoError(t, err) + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + params := &address.Parameters{ + ClientPubkey: clientKey, + ServerPubkey: serverKey, + PkScript: pkScript, + Expiry: 1_000, + KeyLocator: keychain.KeyLocator{ + Family: 99, + Index: keyIndex, + }, + ProtocolVersion: version.ProtocolVersion_V0, + InitiationHeight: 100, + } + require.NoError(t, store.CreateStaticAddress(ctx, params)) + params.ID, err = store.GetStaticAddressID(ctx, params.PkScript) + require.NoError(t, err) + + return params +} + +func createIntegrationDeposit(t *testing.T, ctx context.Context, + store *deposit.SqlStore, hashByte byte, value btcutil.Amount, + params *address.Parameters) *deposit.Deposit { + + t.Helper() + + id, err := deposit.GetRandomDepositID() + require.NoError(t, err) + d := &deposit.Deposit{ + ID: id, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{hashByte}, + Index: uint32(hashByte), + }, + Value: value, + ConfirmationHeight: 90, + TimeOutSweepPkScript: []byte{0x00, 0x14, hashByte}, + AddressParams: params, + } + d.SetState(deposit.Deposited) + require.NoError(t, store.CreateDeposit(ctx, d)) + require.NoError(t, store.UpdateDeposit(ctx, d)) + + return d +} + +func requireDepositAddressIDs(t *testing.T, deposits []*deposit.Deposit, + want map[string]int32) { + + t.Helper() + require.Len(t, deposits, len(want)) + for _, d := range deposits { + require.NotNil(t, d.AddressParams) + require.Equal(t, want[d.String()], d.AddressParams.ID) + } +} From 1179815d8936b8994a0c5fc63de796d17f2d695e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 27 Aug 2026 12:38:23 +0200 Subject: [PATCH 20/30] docs: document multi-address static deposits Document fresh receive-address derivation, lazy seed initialization, funding-address lookup hardening, and the swap:execute permission required by address creation. Regenerate the CLI, gRPC, Swagger, and man-page documentation and add feature, breaking-change, and recovery release notes. --- cmd/loop/staticaddr.go | 11 ++++++----- docs/loop.md | 2 +- docs/release-notes/release-notes-next.md | 25 ++++++++++++++++++++++++ looprpc/client.proto | 4 +++- looprpc/client.swagger.json | 2 +- looprpc/client_grpc.pb.go | 8 ++++++-- 6 files changed, 42 insertions(+), 10 deletions(-) diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index 83e8f7090..0a0abd299 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -53,11 +53,12 @@ var newStaticAddressCommand = &cli.Command{ Aliases: []string{"n"}, Usage: "Create a new static loop in address.", Description: ` - Creates a new static loop in address. On a fresh installation loopd - initializes the static-address generation during startup. Funds sent to the - address will be locked by a 2:2 multisig between us and the loop server, or - a timeout path that we can sweep once it opens up. The funds can either be - cooperatively spent with a signature from the server or looped in. + Creates a new static loop in address. On a fresh installation, loopd creates + the static-address seed lazily when the first address is requested; startup + alone does not create an address. Funds sent to the address will be locked by + a 2:2 multisig between us and the loop server, or a timeout path that we can + sweep once it opens up. The funds can either be cooperatively spent with a + signature from the server or looped in. `, Action: newStaticAddress, } diff --git a/docs/loop.md b/docs/loop.md index 63ed338a8..eafb1303a 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -541,7 +541,7 @@ The following flags are supported: Create a new static loop in address. -Creates a new static loop in address. On a fresh installation loopd initializes the static-address generation during startup. Funds sent to the address will be locked by a 2:2 multisig between us and the loop server, or a timeout path that we can sweep once it opens up. The funds can either be cooperatively spent with a signature from the server or looped in. +Creates a new static loop in address. On a fresh installation, loopd creates the static-address seed lazily when the first address is requested; startup alone does not create an address. Funds sent to the address will be locked by a 2:2 multisig between us and the loop server, or a timeout path that we can sweep once it opens up. The funds can either be cooperatively spent with a signature from the server or looped in. Usage: diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 8cea37ddd..3d967ed35 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -7,6 +7,12 @@ record is unavailable. It can also request the server's cooperative MuSig2 signature to spend an original Taproot HTLC through its key path. +* Static Address now derives fresh receive and change addresses while retaining + per-deposit address ownership across restarts for discovery, recovery, and + signing. The new `loop static deposit` command can create and fund an address + directly from the lnd wallet, and deposit listings identify the receiving + address. [PR #1139](https://github.com/lightninglabs/loop/pull/1139) + #### Breaking Changes * Instant Out and reservation RPCs now require the `loop:out` permission. @@ -14,8 +20,27 @@ `ListReservations`, `InstantOut`, `InstantOutQuote`, or `ListInstantOuts`. [PR #1194](https://github.com/lightninglabs/loop/pull/1194) +* Calling `NewStaticAddress` without `send_coins_request.addr` now derives and + returns a fresh receive address instead of reusing the address associated + with the client's L402. Integrations must not assume that repeated calls are + idempotent or return the same address. The RPC now requires the + `swap:execute` permission instead of `swap:read`, including for address-only + calls. Operators using custom scoped macaroons must rebake them accordingly. + The deprecated `StaticAddressSummaryResponse.static_address` field remains + the legacy/root address for compatibility and must not be treated as the + current receive address; call `NewStaticAddress` to derive a fresh one. + [PR #1139](https://github.com/lightninglabs/loop/pull/1139) + #### Bug Fixes +* Static Address startup now avoids reimporting wallet scripts that lnd already + watches, address lookups remain responsive while new addresses are issued, + and seed creation can recover from a failed wallet import. + +* `loop static deposit` now resolves requested existing funding addresses + directly by script, and wallet-import errors are ignored only when they + identify the exact script that lnd already watches. + * Loop Out requests now account for channel reserves when checking outbound capacity, preventing swaps from starting when their off-chain payment cannot be funded. diff --git a/looprpc/client.proto b/looprpc/client.proto index ca9dbee57..40532536d 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -163,7 +163,9 @@ service SwapClient { returns (ListInstantOutsResponse); /* loop: `static newstaticaddress` - NewStaticAddress requests a new static address for loop-ins from the server. + NewStaticAddress derives a fresh static receive address on every request + without send_coins_request.addr, or funds an existing address when addr is + set. */ rpc NewStaticAddress (NewStaticAddressRequest) returns (NewStaticAddressResponse); diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index 98343aeb3..330401687 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -870,7 +870,7 @@ }, "/v1/staticaddr": { "post": { - "summary": "loop: `static newstaticaddress`\nNewStaticAddress requests a new static address for loop-ins from the server.", + "summary": "loop: `static newstaticaddress`\nNewStaticAddress derives a fresh static receive address on every request\nwithout send_coins_request.addr, or funds an existing address when addr is\nset.", "operationId": "SwapClient_NewStaticAddress", "responses": { "200": { diff --git a/looprpc/client_grpc.pb.go b/looprpc/client_grpc.pb.go index 665a81073..a85a1b1db 100644 --- a/looprpc/client_grpc.pb.go +++ b/looprpc/client_grpc.pb.go @@ -114,7 +114,9 @@ type SwapClientClient interface { // their current status. ListInstantOuts(ctx context.Context, in *ListInstantOutsRequest, opts ...grpc.CallOption) (*ListInstantOutsResponse, error) // loop: `static newstaticaddress` - // NewStaticAddress requests a new static address for loop-ins from the server. + // NewStaticAddress derives a fresh static receive address on every request + // without send_coins_request.addr, or funds an existing address when addr is + // set. NewStaticAddress(ctx context.Context, in *NewStaticAddressRequest, opts ...grpc.CallOption) (*NewStaticAddressResponse, error) // loop: `static listunspentdeposits` // ListUnspentDeposits returns a list of utxos deposited at a static address. @@ -574,7 +576,9 @@ type SwapClientServer interface { // their current status. ListInstantOuts(context.Context, *ListInstantOutsRequest) (*ListInstantOutsResponse, error) // loop: `static newstaticaddress` - // NewStaticAddress requests a new static address for loop-ins from the server. + // NewStaticAddress derives a fresh static receive address on every request + // without send_coins_request.addr, or funds an existing address when addr is + // set. NewStaticAddress(context.Context, *NewStaticAddressRequest) (*NewStaticAddressResponse, error) // loop: `static listunspentdeposits` // ListUnspentDeposits returns a list of utxos deposited at a static address. From 23c633015f72af2831db3f83921371c0214d7653 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 27 Aug 2026 10:52:53 +0200 Subject: [PATCH 21/30] staticaddr/withdraw: validate confirmed replacement spends Follow the transaction that actually spends the watched deposit, require its confirmed form to spend every selected withdrawal input, persist that transaction, and stop republishing both the original and replacement. --- staticaddr/withdraw/manager.go | 67 ++++++++++++- staticaddr/withdraw/manager_test.go | 142 ++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 4 deletions(-) diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 106ce1bfa..ee3dba114 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -691,6 +691,33 @@ func withdrawalChangePkScript(tx *wire.MsgTx) []byte { return tx.TxOut[1].PkScript } +// validateConfirmedWithdrawalInputs verifies that a confirmed withdrawal +// transaction spends every deposit associated with the withdrawal. Withdrawal +// monitoring intentionally watches only the first deposit so an RBF replacement +// can be discovered without registering a new confirmation notification for +// every replacement transaction. However, the spend notification also fires if +// an unrelated transaction spends only that first deposit. Requiring the full +// deposit set prevents such a partial spend from incorrectly transitioning all +// deposits to Withdrawn while still permitting a replacement transaction with a +// different transaction ID or additional inputs. +func validateConfirmedWithdrawalInputs(tx *wire.MsgTx, + deposits []*deposit.Deposit) error { + + inputs := make(map[wire.OutPoint]struct{}, len(tx.TxIn)) + for _, txIn := range tx.TxIn { + inputs[txIn.PreviousOutPoint] = struct{}{} + } + + for _, d := range deposits { + if _, ok := inputs[d.OutPoint]; !ok { + return fmt.Errorf("confirmed transaction %v does not spend "+ + "withdrawal deposit %v", tx.TxHash(), d.OutPoint) + } + } + + return nil +} + // handleWithdrawal starts a goroutine that listens for the spent of the first // input of the withdrawal transaction. func (m *Manager) handleWithdrawal(ctx context.Context, @@ -716,14 +743,19 @@ func (m *Manager) handleWithdrawal(ctx context.Context, select { case spentTx := <-spentChan: spendingHeight := uint32(spentTx.SpendingHeight) + spenderTxHash := originalTxHash + if spentTx.SpenderTxHash != nil { + spenderTxHash = *spentTx.SpenderTxHash + } else if spentTx.SpendingTx != nil { + spenderTxHash = spentTx.SpendingTx.TxHash() + } // If the transaction received one confirmation, we // ensure re-org safety by waiting for some more // confirmations. confChan, confErrChan, err := m.cfg.ChainNotifier.RegisterConfirmationsNtfn( - ctx, spentTx.SpenderTxHash, - withdrawalPkScript, + ctx, &spenderTxHash, withdrawalPkScript, MinConfs, int32(m.initiationHeight.Load()), ) @@ -738,6 +770,32 @@ func (m *Manager) handleWithdrawal(ctx context.Context, select { case tx := <-confChan: + confirmedTx := spentTx.SpendingTx + if tx != nil && tx.Tx != nil { + confirmedTx = tx.Tx + } + if confirmedTx == nil { + log.Errorf("Confirmed withdrawal %v "+ + "missing transaction", + spenderTxHash) + + return + } + + // Since the spend notification above only watches the + // first deposit, verify that the confirmed spender is the + // withdrawal (or one of its RBF replacements) before + // transitioning the complete deposit group. + err = validateConfirmedWithdrawalInputs( + confirmedTx, deposits, + ) + if err != nil { + log.Errorf("Ignoring incomplete withdrawal: %v", + err) + + return + } + err = m.cfg.DepositManager.TransitionDeposits( ctx, deposits, deposit.OnWithdrawn, deposit.Withdrawn, @@ -752,12 +810,13 @@ func (m *Manager) handleWithdrawal(ctx context.Context, // arrivals. m.mu.Lock() delete(m.finalizedWithdrawalTxns, originalTxHash) + delete(m.finalizedWithdrawalTxns, spenderTxHash) m.mu.Unlock() // Persist info about the finalized withdrawal. err = m.cfg.Store.UpdateWithdrawal( - ctx, deposits, tx.Tx, spendingHeight, - withdrawalChangePkScript(tx.Tx), + ctx, deposits, confirmedTx, spendingHeight, + withdrawalChangePkScript(confirmedTx), ) if err != nil { log.Errorf("Error persisting "+ diff --git a/staticaddr/withdraw/manager_test.go b/staticaddr/withdraw/manager_test.go index 23f243af2..d3b7ab7a4 100644 --- a/staticaddr/withdraw/manager_test.go +++ b/staticaddr/withdraw/manager_test.go @@ -3,6 +3,7 @@ package withdraw import ( "context" "testing" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" @@ -11,11 +12,13 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/funding" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" @@ -81,6 +84,44 @@ func TestWithdrawalChangePkScript(t *testing.T) { require.Equal(t, []byte{0x02}, withdrawalChangePkScript(tx)) } +// TestValidateConfirmedWithdrawalInputs verifies that a replacement +// transaction must preserve the complete withdrawal deposit set. Additional +// inputs are allowed because they do not change which deposits are withdrawn. +func TestValidateConfirmedWithdrawalInputs(t *testing.T) { + t.Parallel() + + first := &deposit.Deposit{ + OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 1}, + } + second := &deposit.Deposit{ + OutPoint: wire.OutPoint{Hash: chainhash.Hash{2}, Index: 2}, + } + deposits := []*deposit.Deposit{first, second} + + partialSpend := wire.NewMsgTx(2) + partialSpend.AddTxIn(&wire.TxIn{ + PreviousOutPoint: first.OutPoint, + }) + err := validateConfirmedWithdrawalInputs(partialSpend, deposits) + require.ErrorContains(t, err, second.OutPoint.String()) + + replacement := wire.NewMsgTx(2) + replacement.AddTxIn(&wire.TxIn{ + PreviousOutPoint: first.OutPoint, + }) + replacement.AddTxIn(&wire.TxIn{ + PreviousOutPoint: second.OutPoint, + }) + replacement.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, Index: 3, + }, + }) + require.NoError( + t, validateConfirmedWithdrawalInputs(replacement, deposits), + ) +} + func TestCreateFinalizedWithdrawalTxCleansUpSessionsOnError(t *testing.T) { clientKey, err := btcec.NewPrivateKey() require.NoError(t, err) @@ -119,6 +160,107 @@ func TestCreateFinalizedWithdrawalTxCleansUpSessionsOnError(t *testing.T) { require.Equal(t, []error{nil}, signer.cleanupCtxErr) } +type withdrawalConfRegistration struct { + txID *chainhash.Hash + pkScript []byte + numConfs int32 + heightHint int32 +} + +type withdrawalTestNotifier struct { + lndclient.ChainNotifierClient + + spendChan chan *chainntnfs.SpendDetail + spendErr chan error + confChan chan *chainntnfs.TxConfirmation + confErr chan error + confReq chan withdrawalConfRegistration +} + +func newWithdrawalTestNotifier() *withdrawalTestNotifier { + return &withdrawalTestNotifier{ + spendChan: make(chan *chainntnfs.SpendDetail, 1), + spendErr: make(chan error, 1), + confChan: make(chan *chainntnfs.TxConfirmation, 1), + confErr: make(chan error, 1), + confReq: make(chan withdrawalConfRegistration, 1), + } +} + +func (n *withdrawalTestNotifier) RegisterSpendNtfn(context.Context, + *wire.OutPoint, []byte, int32, ...lndclient.NotifierOption) ( + chan *chainntnfs.SpendDetail, chan error, error) { + + return n.spendChan, n.spendErr, nil +} + +func (n *withdrawalTestNotifier) RegisterConfirmationsNtfn(_ context.Context, + txid *chainhash.Hash, pkScript []byte, numConfs, heightHint int32, + _ ...lndclient.NotifierOption) (chan *chainntnfs.TxConfirmation, + chan error, error) { + + n.confReq <- withdrawalConfRegistration{ + txID: txid, + pkScript: pkScript, + numConfs: numConfs, + heightHint: heightHint, + } + + return n.confChan, n.confErr, nil +} + +func TestHandleWithdrawalFollowsReplacementTxid(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + UseLogger(btclog.Disabled) + + notifier := newWithdrawalTestNotifier() + manager, err := NewManager(&ManagerConfig{ + ChainNotifier: notifier, + }, 123) + require.NoError(t, err) + + originalTxHash := chainhash.Hash{1} + replacementTxHash := chainhash.Hash{2} + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 0, + }, + ConfirmationHeight: 42, + AddressParams: &address.Parameters{ + PkScript: []byte{0x51}, + }, + } + manager.finalizedWithdrawalTxns[originalTxHash] = wire.NewMsgTx(2) + withdrawalPkScript := []byte{0x51} + + err = manager.handleWithdrawal( + ctx, []*deposit.Deposit{dep}, originalTxHash, + withdrawalPkScript, + ) + require.NoError(t, err) + + notifier.spendChan <- &chainntnfs.SpendDetail{ + SpenderTxHash: &replacementTxHash, + SpendingTx: wire.NewMsgTx(2), + SpendingHeight: 50, + } + + select { + case req := <-notifier.confReq: + require.NotNil(t, req.txID) + require.Equal(t, replacementTxHash, *req.txID) + require.Equal(t, withdrawalPkScript, req.pkScript) + require.Equal(t, MinConfs, req.numConfs) + require.EqualValues(t, 123, req.heightHint) + + case <-ctx.Done(): + t.Fatalf("confirmation registration not received: %v", ctx.Err()) + } +} + // TestSignMusig2Tx_MissingSigningInfo tests that signMusig2Tx should error // when sigInfo is missing an entry for one of the deposits. // From eba1e9393d7223cb296cf8349769f60e51b74cff Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 25 Aug 2026 15:58:17 +0200 Subject: [PATCH 22/30] staticaddr/withdraw: identify change outputs by script Identify withdrawal change through its active change-family script instead of a fixed output index. Record amounts correctly when confirmed replacements reorder or add outputs. --- staticaddr/withdraw/interface.go | 3 ++ staticaddr/withdraw/manager.go | 55 ++++++++++++++++++++++----- staticaddr/withdraw/manager_test.go | 35 +++++++++++++++-- staticaddr/withdraw/sql_store.go | 19 +++++---- staticaddr/withdraw/sql_store_test.go | 12 ++++-- 5 files changed, 100 insertions(+), 24 deletions(-) diff --git a/staticaddr/withdraw/interface.go b/staticaddr/withdraw/interface.go index b2698fc44..169aa8328 100644 --- a/staticaddr/withdraw/interface.go +++ b/staticaddr/withdraw/interface.go @@ -23,6 +23,9 @@ type AddressManager interface { // 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) + + // GetParameters returns active static address parameters for a pkScript. + GetParameters(pkScript []byte) *address.Parameters } type DepositManager interface { diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index ee3dba114..155049afb 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -21,6 +21,7 @@ import ( "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/staticutil" + "github.com/lightninglabs/loop/swap" staticaddressrpc "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/funding" "github.com/lightningnetwork/lnd/input" @@ -683,12 +684,35 @@ func (m *Manager) publishFinalizedWithdrawalTx(ctx context.Context, return true, nil } -func withdrawalChangePkScript(tx *wire.MsgTx) []byte { - if tx == nil || len(tx.TxOut) < 2 { - return nil +func withdrawalChangePkScript(tx *wire.MsgTx, withdrawalPkScript []byte, + addressManager AddressManager) ([]byte, error) { + + if tx == nil || addressManager == nil { + return nil, nil } - return tx.TxOut[1].PkScript + var changePkScript []byte + for _, txOut := range tx.TxOut { + if bytes.Equal(txOut.PkScript, withdrawalPkScript) { + continue + } + + params := addressManager.GetParameters(txOut.PkScript) + if params == nil || int32(params.KeyLocator.Family) != + swap.StaticAddressChangeKeyFamily { + + continue + } + + if changePkScript != nil { + return nil, fmt.Errorf("confirmed withdrawal %v has multiple "+ + "static-address change outputs", tx.TxHash()) + } + + changePkScript = txOut.PkScript + } + + return changePkScript, nil } // validateConfirmedWithdrawalInputs verifies that a confirmed withdrawal @@ -796,6 +820,11 @@ func (m *Manager) handleWithdrawal(ctx context.Context, return } + changePkScript, changeErr := withdrawalChangePkScript( + confirmedTx, withdrawalPkScript, + m.cfg.AddressManager, + ) + err = m.cfg.DepositManager.TransitionDeposits( ctx, deposits, deposit.OnWithdrawn, deposit.Withdrawn, @@ -814,13 +843,19 @@ func (m *Manager) handleWithdrawal(ctx context.Context, m.mu.Unlock() // Persist info about the finalized withdrawal. - err = m.cfg.Store.UpdateWithdrawal( - ctx, deposits, confirmedTx, spendingHeight, - withdrawalChangePkScript(confirmedTx), - ) - if err != nil { + var persistErr error + if changeErr != nil { + log.Errorf("Error identifying withdrawal change: %v", + changeErr) + } else { + persistErr = m.cfg.Store.UpdateWithdrawal( + ctx, deposits, confirmedTx, spendingHeight, + changePkScript, + ) + } + if persistErr != nil { log.Errorf("Error persisting "+ - "withdrawal: %v", err) + "withdrawal: %v", persistErr) } case err := <-confErrChan: diff --git a/staticaddr/withdraw/manager_test.go b/staticaddr/withdraw/manager_test.go index d3b7ab7a4..558cca709 100644 --- a/staticaddr/withdraw/manager_test.go +++ b/staticaddr/withdraw/manager_test.go @@ -16,6 +16,7 @@ import ( "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/chainntnfs" @@ -68,20 +69,48 @@ func TestNewManagerHeightValidation(t *testing.T) { func TestWithdrawalChangePkScript(t *testing.T) { t.Parallel() - require.Nil(t, withdrawalChangePkScript(nil)) + addrManager := &withdrawalTestAddressManager{ + params: make(map[string]*address.Parameters), + } + pkScript, err := withdrawalChangePkScript(nil, nil, addrManager) + require.NoError(t, err) + require.Nil(t, pkScript) tx := wire.NewMsgTx(2) tx.AddTxOut(&wire.TxOut{ Value: 1000, PkScript: []byte{0x01}, }) - require.Nil(t, withdrawalChangePkScript(tx)) + pkScript, err = withdrawalChangePkScript(tx, nil, addrManager) + require.NoError(t, err) + require.Nil(t, pkScript) tx.AddTxOut(&wire.TxOut{ Value: 500, PkScript: []byte{0x02}, }) - require.Equal(t, []byte{0x02}, withdrawalChangePkScript(tx)) + addrManager.params[string([]byte{0x01})] = &address.Parameters{ + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressChangeKeyFamily), + }, + } + pkScript, err = withdrawalChangePkScript( + tx, []byte{0x02}, addrManager, + ) + require.NoError(t, err) + require.Equal(t, []byte{0x01}, pkScript) +} + +type withdrawalTestAddressManager struct { + AddressManager + + params map[string]*address.Parameters +} + +func (m *withdrawalTestAddressManager) GetParameters( + pkScript []byte) *address.Parameters { + + return m.params[string(pkScript)] } // TestValidateConfirmedWithdrawalInputs verifies that a replacement diff --git a/staticaddr/withdraw/sql_store.go b/staticaddr/withdraw/sql_store.go index df6f27f69..297afc5c1 100644 --- a/staticaddr/withdraw/sql_store.go +++ b/staticaddr/withdraw/sql_store.go @@ -119,16 +119,19 @@ func (s *SqlStore) UpdateWithdrawal(ctx context.Context, deposits []*deposit.Deposit, tx *wire.MsgTx, confirmationHeight uint32, changePkScript []byte) error { - // Populate the optional change amount. + // Populate the optional change amount without assuming a fixed output + // count or order, because a confirmed RBF replacement may differ from + // the locally constructed transaction. withdrawnAmount, changeAmount := int64(0), int64(0) - if len(tx.TxOut) == 1 { - withdrawnAmount = tx.TxOut[0].Value - } else if len(tx.TxOut) == 2 { - withdrawnAmount, changeAmount = tx.TxOut[0].Value, tx.TxOut[1].Value - if bytes.Equal(changePkScript, tx.TxOut[0].PkScript) { - changeAmount = tx.TxOut[0].Value - withdrawnAmount = tx.TxOut[1].Value + for _, txOut := range tx.TxOut { + if len(changePkScript) > 0 && + bytes.Equal(changePkScript, txOut.PkScript) { + + changeAmount += txOut.Value + continue } + + withdrawnAmount += txOut.Value } updateArgs := sqlc.UpdateWithdrawalParams{ diff --git a/staticaddr/withdraw/sql_store_test.go b/staticaddr/withdraw/sql_store_test.go index 2b4c9018b..55b040bc7 100644 --- a/staticaddr/withdraw/sql_store_test.go +++ b/staticaddr/withdraw/sql_store_test.go @@ -74,15 +74,21 @@ func TestSqlStore(t *testing.T) { Version: 2, TxOut: []*wire.TxOut{ { - Value: int64(d1.Value + d2.Value - 100), + Value: 100, + PkScript: []byte{ + 0x01, + }, + }, + { + Value: 100_000, PkScript: []byte{ 0x00, }, }, { - Value: int64(100), + Value: int64(d1.Value + d2.Value - 100 - 100_000), PkScript: []byte{ - 0x01, + 0x02, }, }, }, From d403bd54c7c4cb89263522015747bf80fa1f8cae Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 25 Aug 2026 15:59:04 +0200 Subject: [PATCH 23/30] staticaddr/withdraw: reconcile partial confirmed spends Stop republishing an obsolete withdrawal when its confirmed spender consumes only part of the deposit group. Finalize spent deposits, release untouched deposits, and clear stale transaction references. --- staticaddr/withdraw/manager.go | 65 ++++++++++++++- staticaddr/withdraw/manager_test.go | 123 ++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 1 deletion(-) diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 155049afb..d4ac55203 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -18,6 +18,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" "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/staticutil" @@ -742,6 +743,55 @@ func validateConfirmedWithdrawalInputs(tx *wire.MsgTx, return nil } +func (m *Manager) reconcileIncompleteWithdrawal(ctx context.Context, + tx *wire.MsgTx, deposits []*deposit.Deposit) error { + + inputs := make(map[wire.OutPoint]struct{}, len(tx.TxIn)) + for _, txIn := range tx.TxIn { + inputs[txIn.PreviousOutPoint] = struct{}{} + } + + spent := make([]*deposit.Deposit, 0, len(deposits)) + unspent := make([]*deposit.Deposit, 0, len(deposits)) + for _, d := range deposits { + if _, ok := inputs[d.OutPoint]; ok { + spent = append(spent, d) + } else { + unspent = append(unspent, d) + } + } + + var reconcileErr error + if len(spent) > 0 { + reconcileErr = errors.Join( + reconcileErr, + m.cfg.DepositManager.TransitionDeposits( + ctx, spent, deposit.OnWithdrawn, deposit.Withdrawn, + ), + ) + } + if len(unspent) > 0 { + reconcileErr = errors.Join( + reconcileErr, + m.cfg.DepositManager.TransitionDeposits( + ctx, unspent, fsm.OnError, deposit.Deposited, + ), + ) + } + + for _, d := range deposits { + d.Lock() + d.FinalizedWithdrawalTx = nil + d.Unlock() + + reconcileErr = errors.Join( + reconcileErr, m.cfg.DepositManager.UpdateDeposit(ctx, d), + ) + } + + return reconcileErr +} + // handleWithdrawal starts a goroutine that listens for the spent of the first // input of the withdrawal transaction. func (m *Manager) handleWithdrawal(ctx context.Context, @@ -814,9 +864,22 @@ func (m *Manager) handleWithdrawal(ctx context.Context, confirmedTx, deposits, ) if err != nil { - log.Errorf("Ignoring incomplete withdrawal: %v", + log.Errorf("Confirmed incomplete withdrawal: %v", err) + m.mu.Lock() + delete(m.finalizedWithdrawalTxns, originalTxHash) + delete(m.finalizedWithdrawalTxns, spenderTxHash) + m.mu.Unlock() + + err = m.reconcileIncompleteWithdrawal( + ctx, confirmedTx, deposits, + ) + if err != nil { + log.Errorf("Error reconciling incomplete "+ + "withdrawal: %v", err) + } + return } diff --git a/staticaddr/withdraw/manager_test.go b/staticaddr/withdraw/manager_test.go index 558cca709..b0f68e56f 100644 --- a/staticaddr/withdraw/manager_test.go +++ b/staticaddr/withdraw/manager_test.go @@ -14,6 +14,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" "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/swap" @@ -196,6 +197,40 @@ type withdrawalConfRegistration struct { heightHint int32 } +type withdrawalTransition struct { + deposits []*deposit.Deposit + event fsm.EventType + state fsm.StateType +} + +type withdrawalTestDepositManager struct { + DepositManager + + transitions chan withdrawalTransition + updates chan *deposit.Deposit +} + +func (m *withdrawalTestDepositManager) TransitionDeposits(_ context.Context, + deposits []*deposit.Deposit, event fsm.EventType, + state fsm.StateType) error { + + m.transitions <- withdrawalTransition{ + deposits: deposits, + event: event, + state: state, + } + + return nil +} + +func (m *withdrawalTestDepositManager) UpdateDeposit(_ context.Context, + d *deposit.Deposit) error { + + m.updates <- d + + return nil +} + type withdrawalTestNotifier struct { lndclient.ChainNotifierClient @@ -290,6 +325,94 @@ func TestHandleWithdrawalFollowsReplacementTxid(t *testing.T) { } } +func TestHandleWithdrawalReconcilesIncompleteSpend(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + UseLogger(btclog.Disabled) + + notifier := newWithdrawalTestNotifier() + depositManager := &withdrawalTestDepositManager{ + transitions: make(chan withdrawalTransition, 2), + updates: make(chan *deposit.Deposit, 2), + } + manager, err := NewManager(&ManagerConfig{ + ChainNotifier: notifier, + DepositManager: depositManager, + }, 123) + require.NoError(t, err) + + first := &deposit.Deposit{ + OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 1}, + AddressParams: &address.Parameters{ + PkScript: []byte{0x51}, + }, + } + second := &deposit.Deposit{ + OutPoint: wire.OutPoint{Hash: chainhash.Hash{2}, Index: 2}, + AddressParams: &address.Parameters{ + PkScript: []byte{0x52}, + }, + } + originalTx := wire.NewMsgTx(2) + first.FinalizedWithdrawalTx = originalTx + second.FinalizedWithdrawalTx = originalTx + originalTxHash := originalTx.TxHash() + manager.finalizedWithdrawalTxns[originalTxHash] = originalTx + + err = manager.handleWithdrawal( + ctx, []*deposit.Deposit{first, second}, originalTxHash, + []byte{0x53}, + ) + require.NoError(t, err) + + conflictTx := wire.NewMsgTx(2) + conflictTx.AddTxIn(&wire.TxIn{PreviousOutPoint: first.OutPoint}) + conflictTx.AddTxOut(&wire.TxOut{Value: 1000, PkScript: []byte{0x54}}) + conflictHash := conflictTx.TxHash() + notifier.spendChan <- &chainntnfs.SpendDetail{ + SpenderTxHash: &conflictHash, + SpendingTx: conflictTx, + SpendingHeight: 150, + } + + select { + case <-notifier.confReq: + case <-ctx.Done(): + t.Fatalf("confirmation registration not received: %v", ctx.Err()) + } + notifier.confChan <- &chainntnfs.TxConfirmation{Tx: conflictTx} + + var transitions []withdrawalTransition + for range 2 { + select { + case transition := <-depositManager.transitions: + transitions = append(transitions, transition) + case <-ctx.Done(): + t.Fatalf("deposit transition not received: %v", ctx.Err()) + } + } + require.Equal(t, deposit.OnWithdrawn, transitions[0].event) + require.Equal(t, []*deposit.Deposit{first}, transitions[0].deposits) + require.Equal(t, fsm.OnError, transitions[1].event) + require.Equal(t, []*deposit.Deposit{second}, transitions[1].deposits) + + for range 2 { + select { + case <-depositManager.updates: + case <-ctx.Done(): + t.Fatalf("deposit update not received: %v", ctx.Err()) + } + } + require.Nil(t, first.FinalizedWithdrawalTx) + require.Nil(t, second.FinalizedWithdrawalTx) + + manager.mu.Lock() + _, republishesOriginal := manager.finalizedWithdrawalTxns[originalTxHash] + manager.mu.Unlock() + require.False(t, republishesOriginal) +} + // TestSignMusig2Tx_MissingSigningInfo tests that signMusig2Tx should error // when sigInfo is missing an entry for one of the deposits. // From b6180436cbc1af1a2871a2408d2f514df755f62e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 26 Aug 2026 16:12:26 +0200 Subject: [PATCH 24/30] staticaddr/withdraw: join withdrawal monitors handleWithdrawal starts a long-lived spend and confirmation monitor, but the manager did not own its lifecycle. Manager.Run could return while a monitor was still transitioning deposits, updating withdrawal rows, or reading package-level logging state. Track every monitor with a WaitGroup and give the run loop a child context that is canceled on every exit path. Close request delivery, cancel the monitors, and join them before returning. Configure the test logger once and make monitor tests cancel and wait so consecutive race tests cannot overlap through the global logger. --- staticaddr/withdraw/manager.go | 40 +++++++++++++++++------------ staticaddr/withdraw/manager_test.go | 40 +++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index d4ac55203..829a43c97 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -124,6 +124,10 @@ type Manager struct { // mu protects access to finalizedWithdrawalTxns. mu sync.Mutex + // monitorWg tracks withdrawal spend and confirmation monitors. Run + // cancels and joins these goroutines before returning. + monitorWg sync.WaitGroup + // newWithdrawalRequestChan receives a list of outpoints that should be // withdrawn. The request is forwarded to the managers main loop. newWithdrawalRequestChan chan newWithdrawalRequest @@ -159,8 +163,17 @@ func NewManager(cfg *ManagerConfig, currentHeight uint32) (*Manager, error) { // Run runs the deposit withdrawal manager. func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { + runCtx, cancel := context.WithCancel(ctx) + defer func() { + // Stop request delivery and all monitors before allowing the + // manager's owner to tear down its dependencies. + close(m.exitChan) + cancel() + m.monitorWg.Wait() + }() + newBlockChan, newBlockErrChan, err := - m.cfg.ChainNotifier.RegisterBlockEpochNtfn(ctx) + m.cfg.ChainNotifier.RegisterBlockEpochNtfn(runCtx) if err != nil { log.Errorf("unable to register for block epoch "+ @@ -169,7 +182,7 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { return err } - err = m.recoverWithdrawals(ctx) + err = m.recoverWithdrawals(runCtx) if err != nil { log.Errorf("unable to recover withdrawals: %v", err) @@ -183,7 +196,7 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { for { select { case <-newBlockChan: - err = m.republishWithdrawals(ctx) + err = m.republishWithdrawals(runCtx) if err != nil { log.Errorf("Error republishing withdrawals: %v", err) @@ -191,7 +204,7 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { case req := <-m.newWithdrawalRequestChan: txHash, withdrawalAddress, err := m.WithdrawDeposits( - ctx, req.outpoints, req.destAddr, + runCtx, req.outpoints, req.destAddr, req.satPerVbyte, req.amount, ) if err != nil { @@ -209,22 +222,15 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { select { case req.respChan <- resp: - case <-ctx.Done(): - // Notify subroutines that the main loop has - // been canceled. - close(m.exitChan) - - return ctx.Err() + case <-runCtx.Done(): + return runCtx.Err() } case err = <-newBlockErrChan: return err - case <-ctx.Done(): - // Signal subroutines that the manager is exiting. - close(m.exitChan) - - return ctx.Err() + case <-runCtx.Done(): + return runCtx.Err() } } } @@ -813,7 +819,7 @@ func (m *Manager) handleWithdrawal(ctx context.Context, return fmt.Errorf("unable to register spend ntfn: %w", err) } - go func() { + m.monitorWg.Go(func() { select { case spentTx := <-spentChan: spendingHeight := uint32(spentTx.SpendingHeight) @@ -938,7 +944,7 @@ func (m *Manager) handleWithdrawal(ctx context.Context, case <-ctx.Done(): log.Errorf("Withdrawal tx confirmation wait canceled") } - }() + }) return nil } diff --git a/staticaddr/withdraw/manager_test.go b/staticaddr/withdraw/manager_test.go index b0f68e56f..50dbb7825 100644 --- a/staticaddr/withdraw/manager_test.go +++ b/staticaddr/withdraw/manager_test.go @@ -2,6 +2,7 @@ package withdraw import ( "context" + "os" "testing" "time" @@ -30,6 +31,32 @@ import ( "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + UseLogger(btclog.Disabled) + os.Exit(m.Run()) +} + +func cleanupWithdrawalMonitor(t *testing.T, manager *Manager, + cancel context.CancelFunc) { + + t.Helper() + t.Cleanup(func() { + cancel() + + done := make(chan struct{}) + go func() { + manager.monitorWg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("withdrawal monitor did not stop") + } + }) +} + type withdrawalCleanupSigner struct { lndclient.SignerClient @@ -274,16 +301,14 @@ func (n *withdrawalTestNotifier) RegisterConfirmationsNtfn(_ context.Context, } func TestHandleWithdrawalFollowsReplacementTxid(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - UseLogger(btclog.Disabled) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) notifier := newWithdrawalTestNotifier() manager, err := NewManager(&ManagerConfig{ ChainNotifier: notifier, }, 123) require.NoError(t, err) + cleanupWithdrawalMonitor(t, manager, cancel) originalTxHash := chainhash.Hash{1} replacementTxHash := chainhash.Hash{2} @@ -326,10 +351,7 @@ func TestHandleWithdrawalFollowsReplacementTxid(t *testing.T) { } func TestHandleWithdrawalReconcilesIncompleteSpend(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - UseLogger(btclog.Disabled) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) notifier := newWithdrawalTestNotifier() depositManager := &withdrawalTestDepositManager{ @@ -341,6 +363,7 @@ func TestHandleWithdrawalReconcilesIncompleteSpend(t *testing.T) { DepositManager: depositManager, }, 123) require.NoError(t, err) + cleanupWithdrawalMonitor(t, manager, cancel) first := &deposit.Deposit{ OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 1}, @@ -411,6 +434,7 @@ func TestHandleWithdrawalReconcilesIncompleteSpend(t *testing.T) { _, republishesOriginal := manager.finalizedWithdrawalTxns[originalTxHash] manager.mu.Unlock() require.False(t, republishesOriginal) + manager.monitorWg.Wait() } // TestSignMusig2Tx_MissingSigningInfo tests that signMusig2Tx should error From 790108b90e39eff31bbb722019f4ebaf6ccb50c6 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 26 Aug 2026 16:20:32 +0200 Subject: [PATCH 25/30] staticaddr/withdraw: watch the actual replacement output Withdrawal spend monitoring followed an RBF replacement's txid while retaining the original destination script. Confirmation requests match both values, so a replacement paying a newly derived destination could remain invisible and leave deposits stuck in Withdrawing. Build the confirmation request from the actual spending transaction, use a positive spending height as its historical hint with the startup height as the mempool fallback, and identify change from the confirmed transaction's own destination. Persist the confirmation's block height and remove both original and replacement republish entries after resolution. Widen the manager store dependency to an interface so the complete transition and persistence path can be covered without a database, and add replacement, map-cleanup, and mempool-height regression tests. --- staticaddr/withdraw/interface.go | 17 +++ staticaddr/withdraw/manager.go | 99 ++++++++++----- staticaddr/withdraw/manager_test.go | 179 ++++++++++++++++++++++++++-- 3 files changed, 256 insertions(+), 39 deletions(-) diff --git a/staticaddr/withdraw/interface.go b/staticaddr/withdraw/interface.go index 169aa8328..7d54cc841 100644 --- a/staticaddr/withdraw/interface.go +++ b/staticaddr/withdraw/interface.go @@ -10,6 +10,23 @@ import ( "github.com/lightninglabs/loop/staticaddr/script" ) +// WithdrawalStore persists withdrawal intents and their confirmed +// transactions. +type WithdrawalStore interface { + CreateWithdrawal(ctx context.Context, + deposits []*deposit.Deposit) error + + UpdateWithdrawal(ctx context.Context, deposits []*deposit.Deposit, + tx *wire.MsgTx, confirmationHeight uint32, + changePkScript []byte) error + + GetAllWithdrawals(ctx context.Context) ([]Withdrawal, error) +} + +// Assert that the concrete SQL store continues to satisfy the manager's store +// contract. +var _ WithdrawalStore = (*SqlStore)(nil) + // AddressManager handles fetching of address parameters. type AddressManager interface { // GetStaticAddressParameters returns the static address parameters. diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 829a43c97..6d3572638 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -96,7 +96,7 @@ type ManagerConfig struct { // Store is the store that is used to persist the finalized withdrawal // transactions. - Store *SqlStore + Store WithdrawalStore } // newWithdrawalRequest is used to send withdrawal request to the manager main @@ -135,7 +135,9 @@ type Manager struct { // exitChan signals subroutines that the withdrawal manager is exiting. exitChan chan struct{} - // initiationHeight stores the currently best known block height. + // initiationHeight is the manager's startup height. It is used as a + // fallback confirmation height hint for mempool spend notifications, + // which don't carry a block height. initiationHeight atomic.Uint32 // finalizedWithdrawalTxns are the finalized withdrawal transactions @@ -287,9 +289,7 @@ func (m *Manager) recoverWithdrawals(ctx context.Context) error { return err } - err = m.handleWithdrawal( - ctx, deposits, tx.TxHash(), tx.TxOut[0].PkScript, - ) + err = m.handleWithdrawal(ctx, deposits, tx.TxHash()) if err != nil { return err } @@ -455,12 +455,6 @@ func (m *Manager) WithdrawDeposits(ctx context.Context, return "", "", nil } - withdrawalPkScript, err := txscript.PayToAddrScript(withdrawalAddress) - if err != nil { - return "", "", fmt.Errorf("could not get withdrawal "+ - "pkscript: %w", err) - } - // If this is the first time this cluster of deposits is withdrawn, we // start a goroutine that listens for the spent of the first input of // the withdrawal transaction. @@ -475,9 +469,7 @@ func (m *Manager) WithdrawDeposits(ctx context.Context, "withdrawal: %v", err) } - err = m.handleWithdrawal( - ctx, deposits, finalizedTx.TxHash(), withdrawalPkScript, - ) + err = m.handleWithdrawal(ctx, deposits, finalizedTx.TxHash()) if err != nil { return "", "", err } @@ -722,6 +714,23 @@ func withdrawalChangePkScript(tx *wire.MsgTx, withdrawalPkScript []byte, return changePkScript, nil } +// withdrawalDestinationPkScript returns the destination script from a +// withdrawal transaction. Locally constructed withdrawal transactions always +// place the user-controlled destination at output zero. +func withdrawalDestinationPkScript(tx *wire.MsgTx) ([]byte, error) { + if tx == nil { + return nil, errors.New("withdrawal transaction is nil") + } + if len(tx.TxOut) == 0 || tx.TxOut[0] == nil || + len(tx.TxOut[0].PkScript) == 0 { + + return nil, fmt.Errorf("withdrawal transaction %v has no "+ + "destination output", tx.TxHash()) + } + + return tx.TxOut[0].PkScript, nil +} + // validateConfirmedWithdrawalInputs verifies that a confirmed withdrawal // transaction spends every deposit associated with the withdrawal. Withdrawal // monitoring intentionally watches only the first deposit so an RBF replacement @@ -801,8 +810,11 @@ func (m *Manager) reconcileIncompleteWithdrawal(ctx context.Context, // handleWithdrawal starts a goroutine that listens for the spent of the first // input of the withdrawal transaction. func (m *Manager) handleWithdrawal(ctx context.Context, - deposits []*deposit.Deposit, originalTxHash chainhash.Hash, - withdrawalPkScript []byte) error { + deposits []*deposit.Deposit, originalTxHash chainhash.Hash) error { + + if len(deposits) == 0 { + return errors.New("can't monitor withdrawal without deposits") + } d := deposits[0] if d.AddressParams == nil { @@ -822,12 +834,26 @@ func (m *Manager) handleWithdrawal(ctx context.Context, m.monitorWg.Go(func() { select { case spentTx := <-spentChan: - spendingHeight := uint32(spentTx.SpendingHeight) - spenderTxHash := originalTxHash - if spentTx.SpenderTxHash != nil { - spenderTxHash = *spentTx.SpenderTxHash - } else if spentTx.SpendingTx != nil { - spenderTxHash = spentTx.SpendingTx.TxHash() + if spentTx == nil || spentTx.SpendingTx == nil { + log.Errorf("Withdrawal spend notification missing " + + "transaction") + + return + } + + spendingTx := spentTx.SpendingTx + spenderTxHash := spendingTx.TxHash() + withdrawalPkScript, err := + withdrawalDestinationPkScript(spendingTx) + if err != nil { + log.Errorf("Invalid withdrawal spend: %v", err) + + return + } + spendingHeight := spentTx.SpendingHeight + heightHint := spendingHeight + if heightHint <= 0 { + heightHint = int32(m.initiationHeight.Load()) } // If the transaction received one confirmation, we @@ -836,8 +862,7 @@ func (m *Manager) handleWithdrawal(ctx context.Context, confChan, confErrChan, err := m.cfg.ChainNotifier.RegisterConfirmationsNtfn( ctx, &spenderTxHash, withdrawalPkScript, - MinConfs, - int32(m.initiationHeight.Load()), + MinConfs, heightHint, ) if err != nil { // TODO(#1087): Retry registration on @@ -889,10 +914,25 @@ func (m *Manager) handleWithdrawal(ctx context.Context, return } - changePkScript, changeErr := withdrawalChangePkScript( - confirmedTx, withdrawalPkScript, - m.cfg.AddressManager, - ) + confirmedWithdrawalPkScript, changeErr := + withdrawalDestinationPkScript(confirmedTx) + var changePkScript []byte + if changeErr == nil { + changePkScript, changeErr = + withdrawalChangePkScript( + confirmedTx, + confirmedWithdrawalPkScript, + m.cfg.AddressManager, + ) + } + + confirmationHeight := uint32(0) + if tx != nil { + confirmationHeight = tx.BlockHeight + } + if confirmationHeight == 0 && spendingHeight > 0 { + confirmationHeight = uint32(spendingHeight) + } err = m.cfg.DepositManager.TransitionDeposits( ctx, deposits, deposit.OnWithdrawn, @@ -918,7 +958,8 @@ func (m *Manager) handleWithdrawal(ctx context.Context, changeErr) } else { persistErr = m.cfg.Store.UpdateWithdrawal( - ctx, deposits, confirmedTx, spendingHeight, + ctx, deposits, confirmedTx, + confirmationHeight, changePkScript, ) } diff --git a/staticaddr/withdraw/manager_test.go b/staticaddr/withdraw/manager_test.go index 50dbb7825..74eb31fda 100644 --- a/staticaddr/withdraw/manager_test.go +++ b/staticaddr/withdraw/manager_test.go @@ -250,6 +250,43 @@ func (m *withdrawalTestDepositManager) TransitionDeposits(_ context.Context, return nil } +type withdrawalStoreUpdate struct { + deposits []*deposit.Deposit + tx *wire.MsgTx + confirmationHeight uint32 + changePkScript []byte +} + +type withdrawalTestStore struct { + updates chan withdrawalStoreUpdate +} + +func (s *withdrawalTestStore) CreateWithdrawal(context.Context, + []*deposit.Deposit) error { + + return nil +} + +func (s *withdrawalTestStore) UpdateWithdrawal(_ context.Context, + deposits []*deposit.Deposit, tx *wire.MsgTx, + confirmationHeight uint32, changePkScript []byte) error { + + s.updates <- withdrawalStoreUpdate{ + deposits: deposits, + tx: tx, + confirmationHeight: confirmationHeight, + changePkScript: changePkScript, + } + + return nil +} + +func (s *withdrawalTestStore) GetAllWithdrawals(context.Context) ( + []Withdrawal, error) { + + return nil, nil +} + func (m *withdrawalTestDepositManager) UpdateDeposit(_ context.Context, d *deposit.Deposit) error { @@ -304,14 +341,35 @@ func TestHandleWithdrawalFollowsReplacementTxid(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), time.Second) notifier := newWithdrawalTestNotifier() + depositManager := &withdrawalTestDepositManager{ + transitions: make(chan withdrawalTransition, 1), + updates: make(chan *deposit.Deposit, 1), + } + store := &withdrawalTestStore{ + updates: make(chan withdrawalStoreUpdate, 1), + } + changePkScript := []byte{0x52} + addressManager := &withdrawalTestAddressManager{ + params: map[string]*address.Parameters{ + string(changePkScript): { + PkScript: changePkScript, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily( + swap.StaticAddressChangeKeyFamily, + ), + }, + }, + }, + } manager, err := NewManager(&ManagerConfig{ - ChainNotifier: notifier, + ChainNotifier: notifier, + DepositManager: depositManager, + AddressManager: addressManager, + Store: store, }, 123) require.NoError(t, err) cleanupWithdrawalMonitor(t, manager, cancel) - originalTxHash := chainhash.Hash{1} - replacementTxHash := chainhash.Hash{2} dep := &deposit.Deposit{ OutPoint: wire.OutPoint{ Hash: chainhash.Hash{3}, @@ -322,18 +380,45 @@ func TestHandleWithdrawalFollowsReplacementTxid(t *testing.T) { PkScript: []byte{0x51}, }, } - manager.finalizedWithdrawalTxns[originalTxHash] = wire.NewMsgTx(2) - withdrawalPkScript := []byte{0x51} + originalDestScript := []byte{0x53} + originalTx := wire.NewMsgTx(2) + originalTx.AddTxIn(&wire.TxIn{PreviousOutPoint: dep.OutPoint}) + originalTx.AddTxOut(&wire.TxOut{ + Value: 10_000, + PkScript: originalDestScript, + }) + originalTxHash := originalTx.TxHash() + + // The replacement deliberately pays a different destination. The + // confirmation registration and change identification must use this + // transaction rather than the original transaction's script. + replacementDestScript := []byte{0x54} + replacementTx := wire.NewMsgTx(2) + replacementTx.AddTxIn(&wire.TxIn{PreviousOutPoint: dep.OutPoint}) + replacementTx.AddTxOut(&wire.TxOut{ + Value: 9_000, + PkScript: replacementDestScript, + }) + replacementTx.AddTxOut(&wire.TxOut{ + Value: 500, + PkScript: changePkScript, + }) + replacementTxHash := replacementTx.TxHash() + + manager.finalizedWithdrawalTxns[originalTxHash] = originalTx + manager.finalizedWithdrawalTxns[replacementTxHash] = replacementTx err = manager.handleWithdrawal( ctx, []*deposit.Deposit{dep}, originalTxHash, - withdrawalPkScript, ) require.NoError(t, err) + // Use an inconsistent reported hash to verify that the actual spending + // transaction is authoritative. + reportedHash := chainhash.Hash{2} notifier.spendChan <- &chainntnfs.SpendDetail{ - SpenderTxHash: &replacementTxHash, - SpendingTx: wire.NewMsgTx(2), + SpenderTxHash: &reportedHash, + SpendingTx: replacementTx, SpendingHeight: 50, } @@ -341,8 +426,83 @@ func TestHandleWithdrawalFollowsReplacementTxid(t *testing.T) { case req := <-notifier.confReq: require.NotNil(t, req.txID) require.Equal(t, replacementTxHash, *req.txID) - require.Equal(t, withdrawalPkScript, req.pkScript) + require.Equal(t, replacementDestScript, req.pkScript) require.Equal(t, MinConfs, req.numConfs) + require.EqualValues(t, 50, req.heightHint) + + case <-ctx.Done(): + t.Fatalf("confirmation registration not received: %v", ctx.Err()) + } + + notifier.confChan <- &chainntnfs.TxConfirmation{ + Tx: replacementTx, + BlockHeight: 55, + } + + select { + case transition := <-depositManager.transitions: + require.Equal(t, deposit.OnWithdrawn, transition.event) + require.Equal(t, deposit.Withdrawn, transition.state) + require.Equal(t, []*deposit.Deposit{dep}, transition.deposits) + + case <-ctx.Done(): + t.Fatalf("deposit transition not received: %v", ctx.Err()) + } + + select { + case update := <-store.updates: + require.Equal(t, []*deposit.Deposit{dep}, update.deposits) + require.Same(t, replacementTx, update.tx) + require.EqualValues(t, 55, update.confirmationHeight) + require.Equal(t, changePkScript, update.changePkScript) + + case <-ctx.Done(): + t.Fatalf("withdrawal update not received: %v", ctx.Err()) + } + + manager.monitorWg.Wait() + manager.mu.Lock() + _, republishesOriginal := manager.finalizedWithdrawalTxns[originalTxHash] + _, republishesReplacement := + manager.finalizedWithdrawalTxns[replacementTxHash] + manager.mu.Unlock() + require.False(t, republishesOriginal) + require.False(t, republishesReplacement) +} + +func TestHandleWithdrawalMempoolSpendHeightHint(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + notifier := newWithdrawalTestNotifier() + manager, err := NewManager(&ManagerConfig{ + ChainNotifier: notifier, + }, 123) + require.NoError(t, err) + cleanupWithdrawalMonitor(t, manager, cancel) + + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}}, + AddressParams: &address.Parameters{ + PkScript: []byte{0x51}, + }, + } + err = manager.handleWithdrawal( + ctx, []*deposit.Deposit{dep}, chainhash.Hash{2}, + ) + require.NoError(t, err) + + spendingTx := wire.NewMsgTx(2) + spendingTx.AddTxIn(&wire.TxIn{PreviousOutPoint: dep.OutPoint}) + spendingTx.AddTxOut(&wire.TxOut{ + Value: 1_000, + PkScript: []byte{0x52}, + }) + notifier.spendChan <- &chainntnfs.SpendDetail{ + SpendingTx: spendingTx, + SpendingHeight: 0, + } + + select { + case req := <-notifier.confReq: require.EqualValues(t, 123, req.heightHint) case <-ctx.Done(): @@ -385,7 +545,6 @@ func TestHandleWithdrawalReconcilesIncompleteSpend(t *testing.T) { err = manager.handleWithdrawal( ctx, []*deposit.Deposit{first, second}, originalTxHash, - []byte{0x53}, ) require.NoError(t, err) From c197185d06280c2673ba542b69dbd4b93aee8799 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 26 Aug 2026 19:30:26 +0200 Subject: [PATCH 26/30] staticaddr/withdraw: require confirmed replacement tx --- staticaddr/withdraw/manager.go | 21 +++---- staticaddr/withdraw/manager_test.go | 92 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 6d3572638..e05633238 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -874,18 +874,22 @@ func (m *Manager) handleWithdrawal(ctx context.Context, } select { - case tx := <-confChan: - confirmedTx := spentTx.SpendingTx - if tx != nil && tx.Tx != nil { - confirmedTx = tx.Tx - } - if confirmedTx == nil { + case tx, ok := <-confChan: + if !ok || tx == nil || tx.Tx == nil { log.Errorf("Confirmed withdrawal %v "+ "missing transaction", spenderTxHash) return } + confirmedTx := tx.Tx + if confirmedTx.TxHash() != spenderTxHash { + log.Errorf("Confirmed withdrawal transaction %v "+ + "does not match registered spender %v", + confirmedTx.TxHash(), spenderTxHash) + + return + } // Since the spend notification above only watches the // first deposit, verify that the confirmed spender is the @@ -926,10 +930,7 @@ func (m *Manager) handleWithdrawal(ctx context.Context, ) } - confirmationHeight := uint32(0) - if tx != nil { - confirmationHeight = tx.BlockHeight - } + confirmationHeight := tx.BlockHeight if confirmationHeight == 0 && spendingHeight > 0 { confirmationHeight = uint32(spendingHeight) } diff --git a/staticaddr/withdraw/manager_test.go b/staticaddr/withdraw/manager_test.go index 74eb31fda..44cd65525 100644 --- a/staticaddr/withdraw/manager_test.go +++ b/staticaddr/withdraw/manager_test.go @@ -510,6 +510,98 @@ func TestHandleWithdrawalMempoolSpendHeightHint(t *testing.T) { } } +func TestHandleWithdrawalRequiresConfirmationTransaction(t *testing.T) { + testCases := []struct { + name string + confirmation *chainntnfs.TxConfirmation + }{ + { + name: "nil confirmation", + }, + { + name: "missing transaction", + confirmation: &chainntnfs.TxConfirmation{}, + }, + { + name: "different transaction", + confirmation: &chainntnfs.TxConfirmation{ + Tx: wire.NewMsgTx(2), + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + notifier := newWithdrawalTestNotifier() + depositManager := &withdrawalTestDepositManager{ + transitions: make(chan withdrawalTransition, 1), + updates: make(chan *deposit.Deposit, 1), + } + store := &withdrawalTestStore{ + updates: make(chan withdrawalStoreUpdate, 1), + } + manager, err := NewManager(&ManagerConfig{ + ChainNotifier: notifier, + DepositManager: depositManager, + Store: store, + }, 123) + require.NoError(t, err) + cleanupWithdrawalMonitor(t, manager, cancel) + + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}}, + AddressParams: &address.Parameters{ + PkScript: []byte{0x51}, + }, + } + spendingTx := wire.NewMsgTx(2) + spendingTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: dep.OutPoint, + }) + spendingTx.AddTxOut(&wire.TxOut{ + Value: 1_000, + PkScript: []byte{0x52}, + }) + spendingTxHash := spendingTx.TxHash() + manager.finalizedWithdrawalTxns[spendingTxHash] = spendingTx + + err = manager.handleWithdrawal( + ctx, []*deposit.Deposit{dep}, spendingTxHash, + ) + require.NoError(t, err) + + notifier.spendChan <- &chainntnfs.SpendDetail{ + SpendingTx: spendingTx, + } + select { + case <-notifier.confReq: + case <-ctx.Done(): + t.Fatalf("confirmation registration not received: %v", + ctx.Err()) + } + notifier.confChan <- testCase.confirmation + manager.monitorWg.Wait() + + select { + case <-depositManager.transitions: + t.Fatal("deposit transitioned without confirmation") + default: + } + select { + case <-store.updates: + t.Fatal("withdrawal persisted without confirmation") + default: + } + + manager.mu.Lock() + _, republishes := manager.finalizedWithdrawalTxns[spendingTxHash] + manager.mu.Unlock() + require.True(t, republishes) + }) + } +} + func TestHandleWithdrawalReconcilesIncompleteSpend(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), time.Second) From 3aea2b1db080ba0db0b4d4a6e3be5989e3f0535c Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 27 Aug 2026 10:09:10 +0200 Subject: [PATCH 27/30] staticaddr/withdraw: cover multi-address replacements --- staticaddr/withdraw/manager_test.go | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/staticaddr/withdraw/manager_test.go b/staticaddr/withdraw/manager_test.go index 44cd65525..3e9763749 100644 --- a/staticaddr/withdraw/manager_test.go +++ b/staticaddr/withdraw/manager_test.go @@ -380,9 +380,21 @@ func TestHandleWithdrawalFollowsReplacementTxid(t *testing.T) { PkScript: []byte{0x51}, }, } + secondDep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{4}, + Index: 1, + }, + ConfirmationHeight: 43, + AddressParams: &address.Parameters{ + PkScript: []byte{0x55}, + }, + } + deposits := []*deposit.Deposit{dep, secondDep} originalDestScript := []byte{0x53} originalTx := wire.NewMsgTx(2) originalTx.AddTxIn(&wire.TxIn{PreviousOutPoint: dep.OutPoint}) + originalTx.AddTxIn(&wire.TxIn{PreviousOutPoint: secondDep.OutPoint}) originalTx.AddTxOut(&wire.TxOut{ Value: 10_000, PkScript: originalDestScript, @@ -395,6 +407,9 @@ func TestHandleWithdrawalFollowsReplacementTxid(t *testing.T) { replacementDestScript := []byte{0x54} replacementTx := wire.NewMsgTx(2) replacementTx.AddTxIn(&wire.TxIn{PreviousOutPoint: dep.OutPoint}) + replacementTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: secondDep.OutPoint, + }) replacementTx.AddTxOut(&wire.TxOut{ Value: 9_000, PkScript: replacementDestScript, @@ -409,7 +424,7 @@ func TestHandleWithdrawalFollowsReplacementTxid(t *testing.T) { manager.finalizedWithdrawalTxns[replacementTxHash] = replacementTx err = manager.handleWithdrawal( - ctx, []*deposit.Deposit{dep}, originalTxHash, + ctx, deposits, originalTxHash, ) require.NoError(t, err) @@ -443,7 +458,7 @@ func TestHandleWithdrawalFollowsReplacementTxid(t *testing.T) { case transition := <-depositManager.transitions: require.Equal(t, deposit.OnWithdrawn, transition.event) require.Equal(t, deposit.Withdrawn, transition.state) - require.Equal(t, []*deposit.Deposit{dep}, transition.deposits) + require.Equal(t, deposits, transition.deposits) case <-ctx.Done(): t.Fatalf("deposit transition not received: %v", ctx.Err()) @@ -451,7 +466,7 @@ func TestHandleWithdrawalFollowsReplacementTxid(t *testing.T) { select { case update := <-store.updates: - require.Equal(t, []*deposit.Deposit{dep}, update.deposits) + require.Equal(t, deposits, update.deposits) require.Same(t, replacementTx, update.tx) require.EqualValues(t, 55, update.confirmationHeight) require.Equal(t, changePkScript, update.changePkScript) From 042bc9ba32fb91d89b9e32628185dcc0c8a1bbfa Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 27 Aug 2026 10:09:17 +0200 Subject: [PATCH 28/30] docs: note withdrawal replacement hardening --- docs/release-notes/release-notes-next.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 3d967ed35..c611028f3 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -33,6 +33,10 @@ #### Bug Fixes +* Static Address withdrawals now follow the transaction that actually replaces + an original withdrawal, reconcile partial conflicting spends, and wait for + their background monitors during shutdown. + * Static Address startup now avoids reimporting wallet scripts that lnd already watches, address lookups remain responsive while new addresses are issued, and seed creation can recover from a failed wallet import. From 8bd46e4f050833d1a1ef60c5d1879cd2501e8aba Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 27 Aug 2026 10:45:22 +0200 Subject: [PATCH 29/30] 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 1f738ef5c1dfb9edb7df290425ec3056b50b6590 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 28 Aug 2026 12:18:50 +0200 Subject: [PATCH 30/30] 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 c611028f3..c8f7efa25 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -33,6 +33,9 @@ #### Bug Fixes +* 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, and wait for their background monitors during shutdown.