From 3edbc43deaa5d5381f1e2a05e599c7006c6b4d2c Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 11:55:44 +0200 Subject: [PATCH 1/6] 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 5bf93c3f06d0f4f92ac0085e140f9e9e425467a0 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 11:58:03 +0200 Subject: [PATCH 2/6] 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 | 9 +- .../sqlc/queries/static_address_deposits.sql | 54 +++++- loopdb/sqlc/queries/static_addresses.sql | 14 +- loopdb/sqlc/static_address_deposits.sql.go | 167 ++++++++++++++-- loopdb/sqlc/static_address_loopin.sql.go | 4 +- loopdb/sqlc/static_addresses.sql.go | 36 ++++ staticaddr/address/sql_store.go | 22 ++- staticaddr/deposit/deposit.go | 20 ++ staticaddr/deposit/sql_store.go | 181 +++++++++++++++++- staticaddr/deposit/sql_store_test.go | 18 +- staticaddr/loopin/sql_store.go | 2 +- staticaddr/script/parameters.go | 4 + 15 files changed, 498 insertions(+), 43 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..1eed8060c 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) @@ -68,6 +70,7 @@ type Querier interface { OverrideSelectedSwapAmount(ctx context.Context, arg OverrideSelectedSwapAmountParams) error OverrideSwapCosts(ctx context.Context, arg OverrideSwapCostsParams) error RecordStaticAddressRiskDecision(ctx context.Context, arg RecordStaticAddressRiskDecisionParams) error + SetAllNullDepositsStaticAddressID(ctx context.Context, staticAddressID sql.NullInt32) error SwapHashForDepositID(ctx context.Context, depositID []byte) ([]byte, error) UpdateBatch(ctx context.Context, arg UpdateBatchParams) error UpdateDeposit(ctx context.Context, arg UpdateDepositParams) error diff --git a/loopdb/sqlc/queries/static_address_deposits.sql b/loopdb/sqlc/queries/static_address_deposits.sql index 2987e469e..e9b912fef 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,9 @@ WHERE deposit_id = $1 ORDER BY update_timestamp DESC -LIMIT 1; \ No newline at end of file +LIMIT 1; + +-- name: SetAllNullDepositsStaticAddressID :exec +UPDATE deposits +SET static_address_id = $1 +WHERE static_address_id IS NULL; 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..ed23f4209 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 } @@ -209,6 +333,17 @@ func (q *Queries) InsertDepositUpdate(ctx context.Context, arg InsertDepositUpda return err } +const setAllNullDepositsStaticAddressID = `-- name: SetAllNullDepositsStaticAddressID :exec +UPDATE deposits +SET static_address_id = $1 +WHERE static_address_id IS NULL +` + +func (q *Queries) SetAllNullDepositsStaticAddressID(ctx context.Context, staticAddressID sql.NullInt32) error { + _, err := q.db.ExecContext(ctx, setAllNullDepositsStaticAddressID, staticAddressID) + return err +} + const updateDeposit = `-- name: UpdateDeposit :exec UPDATE deposits SET diff --git a/loopdb/sqlc/static_address_loopin.sql.go b/loopdb/sqlc/static_address_loopin.sql.go index 319340168..f6c896ce6 100644 --- a/loopdb/sqlc/static_address_loopin.sql.go +++ b/loopdb/sqlc/static_address_loopin.sql.go @@ -45,7 +45,7 @@ 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, u.update_state, u.update_timestamp FROM @@ -73,6 +73,7 @@ type DepositsForSwapHashRow struct { ExpirySweepTxid []byte FinalizedWithdrawalTx sql.NullString SwapHash []byte + StaticAddressID sql.NullInt32 UpdateState sql.NullString UpdateTimestamp sql.NullTime } @@ -97,6 +98,7 @@ func (q *Queries) DepositsForSwapHash(ctx context.Context, swapHash []byte) ([]D &i.ExpirySweepTxid, &i.FinalizedWithdrawalTx, &i.SwapHash, + &i.StaticAddressID, &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/sql_store.go b/staticaddr/deposit/sql_store.go index a49550e5c..dcacded57 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,57 @@ 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 +} + +// BatchSetStaticAddressID sets the static address id for all deposits that +// predate the deposit-to-address schema link. +func (s *SqlStore) BatchSetStaticAddressID(ctx context.Context, + staticAddressID int32) error { + + if staticAddressID <= 0 { + return fmt.Errorf("static address ID must be set") + } + + return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(), + func(q *sqlc.Queries) error { + return q.SetAllNullDepositsStaticAddressID( + ctx, sql.NullInt32{ + Int32: staticAddressID, + Valid: true, + }, + ) + }) } diff --git a/staticaddr/deposit/sql_store_test.go b/staticaddr/deposit/sql_store_test.go index 5656e386a..4045b2811 100644 --- a/staticaddr/deposit/sql_store_test.go +++ b/staticaddr/deposit/sql_store_test.go @@ -1,6 +1,7 @@ package deposit import ( + "context" "database/sql" "testing" @@ -8,10 +9,21 @@ import ( "github.com/jackc/pgx/v5" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/loopdb/sqlc" + "github.com/lightninglabs/loop/staticaddr/script" "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") +} + func TestToDeposit(t *testing.T) { depositID, err := GetRandomDepositID() require.NoError(t, err) @@ -24,13 +36,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 +56,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..d5f712714 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, 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 5076730d30085a3bcbc717577e7153a27b3798aa Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 25 Aug 2026 18:52:40 +0200 Subject: [PATCH 3/6] docs: add multi-address foundation release note --- docs/release-notes/release-notes-next.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 8cea37ddd..a569210e0 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -31,6 +31,11 @@ #### Maintenance +* Reserved distinct static-address receive and change key families and + persisted deposit address ownership metadata in preparation for multi-address + Static Address Loop In support. + [PR #1210](https://github.com/lightninglabs/loop/pull/1210) + * Updated the gRPC dependency to v1.83.1. * Updated the Taproot Assets dependency to v0.8.1; asset conversions that From 22c590a0a0cfc928a737f3144c021770295e5e7e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 26 Aug 2026 11:45:41 +0200 Subject: [PATCH 4/6] staticaddr: harden deposit address ownership --- loopdb/migration_22_deposit_address_test.go | 101 +++++++++++ .../000022_deposit_static_address_id.up.sql | 2 +- loopdb/sqlc/querier.go | 10 +- .../sqlc/queries/static_address_deposits.sql | 14 +- loopdb/sqlc/queries/static_address_loopin.sql | 10 +- loopdb/sqlc/static_address_deposits.sql.go | 129 +++++++++++--- loopdb/sqlc/static_address_loopin.sql.go | 25 +++ staticaddr/deposit/deposit.go | 30 +++- staticaddr/deposit/deposit_test.go | 53 ++++++ staticaddr/deposit/fsm.go | 9 +- staticaddr/deposit/manager.go | 15 ++ staticaddr/deposit/manager_reconcile_test.go | 18 +- staticaddr/deposit/manager_test.go | 30 +--- staticaddr/deposit/sql_store.go | 83 ++++----- staticaddr/deposit/sql_store_test.go | 165 +++++++++++++++++- .../loopin/deposit_swaphash_migration_test.go | 3 + .../loopin/selected_amount_migration_test.go | 3 + staticaddr/loopin/sql_store.go | 14 +- staticaddr/loopin/sql_store_test.go | 54 ++++++ staticaddr/withdraw/sql_store_test.go | 46 +++++ 20 files changed, 690 insertions(+), 124 deletions(-) create mode 100644 loopdb/migration_22_deposit_address_test.go diff --git a/loopdb/migration_22_deposit_address_test.go b/loopdb/migration_22_deposit_address_test.go new file mode 100644 index 000000000..3b86f2530 --- /dev/null +++ b/loopdb/migration_22_deposit_address_test.go @@ -0,0 +1,101 @@ +package loopdb + +import ( + "context" + "database/sql" + "io/fs" + "net/http" + "path/filepath" + "testing" + "testing/fstest" + + "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" +) + +// TestDepositAddressBackfill verifies that migration 22 only assigns legacy +// deposits when their static-address owner is unambiguous. +func TestDepositAddressBackfill(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + addressCount int + wantOwner bool + }{ + {name: "no address", addressCount: 0}, + {name: "one address", addressCount: 1, wantOwner: true}, + {name: "multiple addresses", addressCount: 2}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + db, err := sql.Open( + "sqlite", filepath.Join(t.TempDir(), "loop.db"), + ) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, db.Close()) + }) + + ctx := context.Background() + _, err = db.ExecContext(ctx, ` + CREATE TABLE static_addresses (id INTEGER PRIMARY KEY); + CREATE TABLE deposits (id INTEGER PRIMARY KEY);`) + require.NoError(t, err) + + migrationSQL, err := fs.ReadFile( + sqlSchemas, + "sqlc/migrations/000022_deposit_static_address_id.up.sql", + ) + require.NoError(t, err) + migrationFS := fstest.MapFS{ + "migrations/000001_deposit_address.up.sql": { + Data: migrationSQL, + }, + } + + driver, err := sqlite_migrate.WithInstance( + db, &sqlite_migrate.Config{}, + ) + require.NoError(t, err) + source, err := httpfs.New( + http.FS(migrationFS), "migrations", + ) + require.NoError(t, err) + migrator, err := migrate.NewWithInstance( + "migrations", source, "sqlc", driver, + ) + require.NoError(t, err) + + for i := 0; i < testCase.addressCount; i++ { + _, err := db.ExecContext(ctx, + "INSERT INTO static_addresses (id) VALUES (?)", + i+1, + ) + require.NoError(t, err) + } + + _, err = db.ExecContext(ctx, + "INSERT INTO deposits (id) VALUES (1)", + ) + require.NoError(t, err) + + require.NoError(t, migrator.Up()) + + var owner sql.NullInt64 + err = db.QueryRowContext(ctx, + "SELECT static_address_id FROM deposits", + ).Scan(&owner) + require.NoError(t, err) + require.Equal(t, testCase.wantOwner, owner.Valid) + if testCase.wantOwner { + require.EqualValues(t, 1, owner.Int64) + } + }) + } +} diff --git a/loopdb/sqlc/migrations/000022_deposit_static_address_id.up.sql b/loopdb/sqlc/migrations/000022_deposit_static_address_id.up.sql index 4246b116e..cc9676b88 100644 --- a/loopdb/sqlc/migrations/000022_deposit_static_address_id.up.sql +++ b/loopdb/sqlc/migrations/000022_deposit_static_address_id.up.sql @@ -5,4 +5,4 @@ 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); + AND (SELECT COUNT(*) FROM static_addresses) = 1; diff --git a/loopdb/sqlc/querier.go b/loopdb/sqlc/querier.go index 1eed8060c..8bdc95508 100644 --- a/loopdb/sqlc/querier.go +++ b/loopdb/sqlc/querier.go @@ -10,7 +10,8 @@ import ( ) type Querier interface { - AllDeposits(ctx context.Context) ([]AllDepositsRow, error) + AllDeposits(ctx context.Context) ([]Deposit, error) + AllDepositsWithAddress(ctx context.Context) ([]AllDepositsWithAddressRow, error) AllStaticAddresses(ctx context.Context) ([]StaticAddress, error) CancelBatch(ctx context.Context, id int32) error CreateDeposit(ctx context.Context, arg CreateDepositParams) error @@ -18,14 +19,16 @@ 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) (DepositForOutpointRow, error) + DepositForOutpoint(ctx context.Context, arg DepositForOutpointParams) (Deposit, error) + DepositForOutpointWithAddress(ctx context.Context, arg DepositForOutpointWithAddressParams) (DepositForOutpointWithAddressRow, 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) (GetDepositRow, error) + GetDeposit(ctx context.Context, depositID []byte) (Deposit, error) + GetDepositWithAddress(ctx context.Context, depositID []byte) (GetDepositWithAddressRow, error) GetInstantOutSwap(ctx context.Context, swapHash []byte) (GetInstantOutSwapRow, error) GetInstantOutSwapUpdates(ctx context.Context, swapHash []byte) ([]InstantoutUpdate, error) GetInstantOutSwaps(ctx context.Context) ([]GetInstantOutSwapsRow, error) @@ -70,7 +73,6 @@ type Querier interface { OverrideSelectedSwapAmount(ctx context.Context, arg OverrideSelectedSwapAmountParams) error OverrideSwapCosts(ctx context.Context, arg OverrideSwapCostsParams) error RecordStaticAddressRiskDecision(ctx context.Context, arg RecordStaticAddressRiskDecisionParams) error - SetAllNullDepositsStaticAddressID(ctx context.Context, staticAddressID sql.NullInt32) error SwapHashForDepositID(ctx context.Context, depositID []byte) ([]byte, error) UpdateBatch(ctx context.Context, arg UpdateBatchParams) error UpdateDeposit(ctx context.Context, arg UpdateDepositParams) error diff --git a/loopdb/sqlc/queries/static_address_deposits.sql b/loopdb/sqlc/queries/static_address_deposits.sql index e9b912fef..8d7463bf1 100644 --- a/loopdb/sqlc/queries/static_address_deposits.sql +++ b/loopdb/sqlc/queries/static_address_deposits.sql @@ -44,6 +44,9 @@ INSERT INTO deposit_updates ( ); -- name: GetDeposit :one +SELECT * FROM deposits WHERE deposit_id = $1; + +-- name: GetDepositWithAddress :one SELECT d.*, sa.client_pubkey client_pubkey, @@ -61,6 +64,9 @@ WHERE deposit_id = $1; -- name: DepositForOutpoint :one +SELECT * FROM deposits WHERE tx_hash = $1 AND out_index = $2; + +-- name: DepositForOutpointWithAddress :one SELECT d.*, sa.client_pubkey client_pubkey, @@ -80,6 +86,9 @@ AND out_index = $2; -- name: AllDeposits :many +SELECT * FROM deposits ORDER BY id ASC; + +-- name: AllDepositsWithAddress :many SELECT d.*, sa.client_pubkey client_pubkey, @@ -106,8 +115,3 @@ WHERE ORDER BY update_timestamp DESC LIMIT 1; - --- name: SetAllNullDepositsStaticAddressID :exec -UPDATE deposits -SET static_address_id = $1 -WHERE static_address_id IS NULL; 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/static_address_deposits.sql.go b/loopdb/sqlc/static_address_deposits.sql.go index ed23f4209..c4883997d 100644 --- a/loopdb/sqlc/static_address_deposits.sql.go +++ b/loopdb/sqlc/static_address_deposits.sql.go @@ -12,6 +12,45 @@ 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, static_address_id FROM deposits ORDER BY id ASC +` + +func (q *Queries) AllDeposits(ctx context.Context) ([]Deposit, error) { + rows, err := q.db.QueryContext(ctx, allDeposits) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Deposit + for rows.Next() { + var i Deposit + if err := rows.Scan( + &i.ID, + &i.DepositID, + &i.TxHash, + &i.OutIndex, + &i.Amount, + &i.ConfirmationHeight, + &i.TimeoutSweepPkScript, + &i.ExpirySweepTxid, + &i.FinalizedWithdrawalTx, + &i.SwapHash, + &i.StaticAddressID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const allDepositsWithAddress = `-- name: AllDepositsWithAddress :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.static_address_id, sa.client_pubkey client_pubkey, @@ -29,7 +68,7 @@ ORDER BY d.id ASC ` -type AllDepositsRow struct { +type AllDepositsWithAddressRow struct { ID int32 DepositID []byte TxHash []byte @@ -51,15 +90,15 @@ type AllDepositsRow struct { InitiationHeight sql.NullInt32 } -func (q *Queries) AllDeposits(ctx context.Context) ([]AllDepositsRow, error) { - rows, err := q.db.QueryContext(ctx, allDeposits) +func (q *Queries) AllDepositsWithAddress(ctx context.Context) ([]AllDepositsWithAddressRow, error) { + rows, err := q.db.QueryContext(ctx, allDepositsWithAddress) if err != nil { return nil, err } defer rows.Close() - var items []AllDepositsRow + var items []AllDepositsWithAddressRow for rows.Next() { - var i AllDepositsRow + var i AllDepositsWithAddressRow if err := rows.Scan( &i.ID, &i.DepositID, @@ -146,6 +185,34 @@ func (q *Queries) CreateDeposit(ctx context.Context, arg CreateDepositParams) er } 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, static_address_id FROM deposits WHERE tx_hash = $1 AND out_index = $2 +` + +type DepositForOutpointParams struct { + TxHash []byte + OutIndex int32 +} + +func (q *Queries) DepositForOutpoint(ctx context.Context, arg DepositForOutpointParams) (Deposit, error) { + row := q.db.QueryRowContext(ctx, depositForOutpoint, arg.TxHash, arg.OutIndex) + var i Deposit + err := row.Scan( + &i.ID, + &i.DepositID, + &i.TxHash, + &i.OutIndex, + &i.Amount, + &i.ConfirmationHeight, + &i.TimeoutSweepPkScript, + &i.ExpirySweepTxid, + &i.FinalizedWithdrawalTx, + &i.SwapHash, + &i.StaticAddressID, + ) + return i, err +} + +const depositForOutpointWithAddress = `-- name: DepositForOutpointWithAddress :one 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.static_address_id, sa.client_pubkey client_pubkey, @@ -165,12 +232,12 @@ AND out_index = $2 ` -type DepositForOutpointParams struct { +type DepositForOutpointWithAddressParams struct { TxHash []byte OutIndex int32 } -type DepositForOutpointRow struct { +type DepositForOutpointWithAddressRow struct { ID int32 DepositID []byte TxHash []byte @@ -192,9 +259,9 @@ type DepositForOutpointRow struct { 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 DepositForOutpointRow +func (q *Queries) DepositForOutpointWithAddress(ctx context.Context, arg DepositForOutpointWithAddressParams) (DepositForOutpointWithAddressRow, error) { + row := q.db.QueryRowContext(ctx, depositForOutpointWithAddress, arg.TxHash, arg.OutIndex) + var i DepositForOutpointWithAddressRow err := row.Scan( &i.ID, &i.DepositID, @@ -220,6 +287,29 @@ func (q *Queries) DepositForOutpoint(ctx context.Context, arg DepositForOutpoint } 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, static_address_id FROM deposits WHERE deposit_id = $1 +` + +func (q *Queries) GetDeposit(ctx context.Context, depositID []byte) (Deposit, error) { + row := q.db.QueryRowContext(ctx, getDeposit, depositID) + var i Deposit + err := row.Scan( + &i.ID, + &i.DepositID, + &i.TxHash, + &i.OutIndex, + &i.Amount, + &i.ConfirmationHeight, + &i.TimeoutSweepPkScript, + &i.ExpirySweepTxid, + &i.FinalizedWithdrawalTx, + &i.SwapHash, + &i.StaticAddressID, + ) + return i, err +} + +const getDepositWithAddress = `-- name: GetDepositWithAddress :one 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.static_address_id, sa.client_pubkey client_pubkey, @@ -237,7 +327,7 @@ WHERE deposit_id = $1 ` -type GetDepositRow struct { +type GetDepositWithAddressRow struct { ID int32 DepositID []byte TxHash []byte @@ -259,9 +349,9 @@ type GetDepositRow struct { InitiationHeight sql.NullInt32 } -func (q *Queries) GetDeposit(ctx context.Context, depositID []byte) (GetDepositRow, error) { - row := q.db.QueryRowContext(ctx, getDeposit, depositID) - var i GetDepositRow +func (q *Queries) GetDepositWithAddress(ctx context.Context, depositID []byte) (GetDepositWithAddressRow, error) { + row := q.db.QueryRowContext(ctx, getDepositWithAddress, depositID) + var i GetDepositWithAddressRow err := row.Scan( &i.ID, &i.DepositID, @@ -333,17 +423,6 @@ func (q *Queries) InsertDepositUpdate(ctx context.Context, arg InsertDepositUpda return err } -const setAllNullDepositsStaticAddressID = `-- name: SetAllNullDepositsStaticAddressID :exec -UPDATE deposits -SET static_address_id = $1 -WHERE static_address_id IS NULL -` - -func (q *Queries) SetAllNullDepositsStaticAddressID(ctx context.Context, staticAddressID sql.NullInt32) error { - _, err := q.db.ExecContext(ctx, setAllNullDepositsStaticAddressID, staticAddressID) - return err -} - const updateDeposit = `-- name: UpdateDeposit :exec UPDATE deposits SET diff --git a/loopdb/sqlc/static_address_loopin.sql.go b/loopdb/sqlc/static_address_loopin.sql.go index f6c896ce6..8cb2aef6d 100644 --- a/loopdb/sqlc/static_address_loopin.sql.go +++ b/loopdb/sqlc/static_address_loopin.sql.go @@ -46,10 +46,19 @@ 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.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 @@ -74,6 +83,14 @@ type DepositsForSwapHashRow struct { 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 } @@ -99,6 +116,14 @@ func (q *Queries) DepositsForSwapHash(ctx context.Context, swapHash []byte) ([]D &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/staticaddr/deposit/deposit.go b/staticaddr/deposit/deposit.go index 8d5fa4636..5b85d61ff 100644 --- a/staticaddr/deposit/deposit.go +++ b/staticaddr/deposit/deposit.go @@ -1,6 +1,7 @@ package deposit import ( + "bytes" "crypto/rand" "fmt" "sync" @@ -10,6 +11,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" ) @@ -166,10 +168,34 @@ func (d *Deposit) GetStaticAddressScript() (*script.StaticAddress, error) { return nil, fmt.Errorf("missing static address parameters") } - return script.NewStaticAddress( - input.MuSig2Version100RC2, int64(d.AddressParams.Expiry), + var muSig2Version input.MuSig2Version + switch d.AddressParams.ProtocolVersion { + case version.ProtocolVersion_V0: + muSig2Version = input.MuSig2Version100RC2 + + default: + return nil, fmt.Errorf("unsupported static address protocol version: %v", + d.AddressParams.ProtocolVersion) + } + + staticAddress, err := script.NewStaticAddress( + muSig2Version, int64(d.AddressParams.Expiry), d.AddressParams.ClientPubkey, d.AddressParams.ServerPubkey, ) + if err != nil { + return nil, err + } + + pkScript, err := staticAddress.StaticAddressScript() + if err != nil { + return nil, err + } + if !bytes.Equal(pkScript, d.AddressParams.PkScript) { + return nil, fmt.Errorf("reconstructed static address script does not " + + "match persisted pkScript") + } + + return staticAddress, nil } // GetRandomDepositID generates a random deposit ID. diff --git a/staticaddr/deposit/deposit_test.go b/staticaddr/deposit/deposit_test.go index 3215e116a..3740535e3 100644 --- a/staticaddr/deposit/deposit_test.go +++ b/staticaddr/deposit/deposit_test.go @@ -3,9 +3,44 @@ package deposit import ( "testing" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/swap" + "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/require" ) +func testAddressParameters(t *testing.T, id int32) *script.Parameters { + t.Helper() + + _, clientKey := test.CreateKey(id) + _, serverKey := test.CreateKey(id + 100) + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(defaultExpiry), clientKey, + serverKey, + ) + require.NoError(t, err) + + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + return &script.Parameters{ + ID: id, + ClientPubkey: clientKey, + ServerPubkey: serverKey, + PkScript: pkScript, + Expiry: defaultExpiry, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Index: uint32(id), + }, + ProtocolVersion: version.ProtocolVersion_V0, + InitiationHeight: 100, + } +} + // TestDepositIsExpiredUnconfirmed verifies that unconfirmed deposits do not // expire because their CSV timeout has not started yet. func TestDepositIsExpiredUnconfirmed(t *testing.T) { @@ -15,3 +50,21 @@ func TestDepositIsExpiredUnconfirmed(t *testing.T) { require.False(t, d.IsExpired(1_000, 144)) } + +func TestGetStaticAddressScriptValidatesPersistedScript(t *testing.T) { + t.Parallel() + + params := testAddressParameters(t, 1) + d := &Deposit{AddressParams: params} + + _, err := d.GetStaticAddressScript() + require.NoError(t, err) + + d.AddressParams.PkScript = []byte{0x51} + _, err = d.GetStaticAddressScript() + require.ErrorContains(t, err, "does not match persisted pkScript") + + d.AddressParams.ProtocolVersion = 999 + _, err = d.GetStaticAddressScript() + require.ErrorContains(t, err, "unsupported static address protocol version") +} diff --git a/staticaddr/deposit/fsm.go b/staticaddr/deposit/fsm.go index c5bb85c30..ea02e7a1d 100644 --- a/staticaddr/deposit/fsm.go +++ b/staticaddr/deposit/fsm.go @@ -181,13 +181,12 @@ 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) } diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 61fc8e769..62523c7dd 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -376,6 +376,20 @@ 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") + } + if addressParams.ID <= 0 { + return nil, fmt.Errorf("missing static address ID") + } + deposit := &Deposit{ ID: id, state: Deposited, @@ -383,6 +397,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..2a04fc0fc 100644 --- a/staticaddr/deposit/manager_reconcile_test.go +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -14,7 +14,6 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/staticaddr/script" - "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/lnwallet" "github.com/stretchr/testify/mock" @@ -36,13 +35,15 @@ func TestReconcileDepositsSerialized(t *testing.T) { }, } + addressParams := testAddressParameters(t, 1) + addressParams.ProtocolVersion = 999 mockAddressManager := new(mockAddressManager) 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")) + ).Return(addressParams, nil) mockStore := new(mockStore) var createCalls atomic.Int32 @@ -132,13 +133,15 @@ func TestReconcileConfirmedDepositUsesCurrentHeight(t *testing.T) { }, } + addressParams := testAddressParameters(t, 2) + addressParams.ProtocolVersion = 999 mockAddressManager := new(mockAddressManager) 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")) + ).Return(addressParams, nil) mockStore := new(mockStore) mockStore.On( @@ -471,6 +474,7 @@ func TestReconcileDepositsReactivatesReappearedDeposit(t *testing.T) { OutPoint: outpoint, Value: btcutil.Amount(100_000), ConfirmationHeight: 77, + AddressParams: testAddressParameters(t, 3), } deposit.SetState(Deposited) @@ -486,9 +490,7 @@ func TestReconcileDepositsReactivatesReappearedDeposit(t *testing.T) { ).Return([]*lnwallet.Utxo{utxo}, nil) mockAddressManager.On( "GetStaticAddressParameters", mock.Anything, - ).Return(&script.Parameters{ - ProtocolVersion: version.ProtocolVersion_V0, - }, nil) + ).Return(testAddressParameters(t, 4), nil) mockAddressManager.On( "GetStaticAddress", mock.Anything, ).Return((*script.StaticAddress)(nil), nil) @@ -687,9 +689,7 @@ func TestReconcileReplacementDepositCreatesNewDeposit(t *testing.T) { ).Return([]*lnwallet.Utxo{utxo}, nil) mockAddressManager.On( "GetStaticAddressParameters", mock.Anything, - ).Return(&script.Parameters{ - ProtocolVersion: version.ProtocolVersion_V0, - }, nil) + ).Return(testAddressParameters(t, 5), nil) mockAddressManager.On( "GetStaticAddress", mock.Anything, ).Return((*script.StaticAddress)(nil), nil) diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 78663f9a7..64184ff28 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -13,7 +13,6 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/staticaddr/script" - "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/chainntnfs" @@ -521,6 +520,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { }, } require.NoError(t, err) + addressParams := testAddressParameters(t, 1) storedDeposits := []*Deposit{ { ID: ID, @@ -529,6 +529,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { Value: utxo.Value, ConfirmationHeight: 3, TimeOutSweepPkScript: []byte{0x42, 0x21, 0x69}, + AddressParams: addressParams, }, } @@ -543,9 +544,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { var manager *Manager mockAddressManager.On( "GetStaticAddressParameters", mock.Anything, - ).Return(&script.Parameters{ - Expiry: defaultExpiry, - }, nil) + ).Return(addressParams, nil) mockAddressManager.On( "ListUnspent", mock.Anything, mock.Anything, mock.Anything, @@ -595,29 +594,14 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { blockErrChan: blockErrChan, } - staticAddress := generateStaticAddress( - context.Background(), testContext, + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(addressParams.Expiry), + addressParams.ClientPubkey, addressParams.ServerPubkey, ) + require.NoError(t, err) 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 -} diff --git a/staticaddr/deposit/sql_store.go b/staticaddr/deposit/sql_store.go index dcacded57..46301ef73 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{ @@ -151,7 +151,7 @@ func (s *SqlStore) GetDeposit(ctx context.Context, id ID) (*Deposit, error) { var deposit *Deposit err := s.baseDB.ExecTx(ctx, loopdb.NewSqlReadOpts(), func(q *sqlc.Queries) error { - row, err := q.GetDeposit(ctx, id[:]) + row, err := q.GetDepositWithAddress(ctx, id[:]) if err != nil { return err } @@ -191,11 +191,11 @@ func (s *SqlStore) DepositForOutpoint(ctx context.Context, if err != nil { return err } - params := sqlc.DepositForOutpointParams{ + params := sqlc.DepositForOutpointWithAddressParams{ TxHash: op.Hash[:], OutIndex: int32(op.Index), } - row, err := q.DepositForOutpoint(ctx, params) + row, err := q.DepositForOutpointWithAddress(ctx, params) if err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrDepositNotFound @@ -235,7 +235,7 @@ func (s *SqlStore) AllDeposits(ctx context.Context) ([]*Deposit, error) { func(q *sqlc.Queries) error { var err error - deposits, err := q.AllDeposits(ctx) + deposits, err := q.AllDepositsWithAddress(ctx) if err != nil { return err } @@ -248,7 +248,7 @@ func (s *SqlStore) AllDeposits(ctx context.Context) ([]*Deposit, error) { return err } - d, err := ToDeposit(deposit, latestUpdate) + d, err := ToDepositWithAddress(deposit, latestUpdate) if err != nil { return err } @@ -265,11 +265,20 @@ func (s *SqlStore) AllDeposits(ctx context.Context) ([]*Deposit, error) { return allDeposits, nil } -// ToDeposit converts an sql deposit row with joined static address metadata to -// a deposit. -func ToDeposit(row sqlc.AllDepositsRow, lastUpdate sqlc.DepositUpdate) (*Deposit, +// ToDeposit converts an SQL deposit to a deposit. This preserves the original +// exported conversion API for callers that do not have joined static-address +// metadata. +func ToDeposit(row sqlc.Deposit, lastUpdate sqlc.DepositUpdate) (*Deposit, error) { + return toDeposit(depositRowFromDeposit(row), lastUpdate) +} + +// ToDepositWithAddress converts an SQL deposit row with joined static-address +// metadata to a deposit. +func ToDepositWithAddress(row sqlc.AllDepositsWithAddressRow, + lastUpdate sqlc.DepositUpdate) (*Deposit, error) { + return toDeposit(depositRowFromAll(row), lastUpdate) } @@ -294,7 +303,21 @@ type depositRow struct { InitiationHeight sql.NullInt32 } -func depositRowFromAll(row sqlc.AllDepositsRow) depositRow { +func depositRowFromDeposit(row sqlc.Deposit) 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, + } +} + +func depositRowFromAll(row sqlc.AllDepositsWithAddressRow) depositRow { return depositRow{ DepositID: row.DepositID, TxHash: row.TxHash, @@ -317,7 +340,7 @@ func depositRowFromAll(row sqlc.AllDepositsRow) depositRow { } } -func depositRowFromGet(row sqlc.GetDepositRow) depositRow { +func depositRowFromGet(row sqlc.GetDepositWithAddressRow) depositRow { return depositRow{ DepositID: row.DepositID, TxHash: row.TxHash, @@ -340,7 +363,7 @@ func depositRowFromGet(row sqlc.GetDepositRow) depositRow { } } -func depositRowFromOutpoint(row sqlc.DepositForOutpointRow) depositRow { +func depositRowFromOutpoint(row sqlc.DepositForOutpointWithAddressRow) depositRow { return depositRow{ DepositID: row.DepositID, TxHash: row.TxHash, @@ -460,23 +483,3 @@ func toDeposit(row depositRow, lastUpdate sqlc.DepositUpdate) (*Deposit, return deposit, nil } - -// BatchSetStaticAddressID sets the static address id for all deposits that -// predate the deposit-to-address schema link. -func (s *SqlStore) BatchSetStaticAddressID(ctx context.Context, - staticAddressID int32) error { - - if staticAddressID <= 0 { - return fmt.Errorf("static address ID must be set") - } - - return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(), - func(q *sqlc.Queries) error { - return q.SetAllNullDepositsStaticAddressID( - ctx, sql.NullInt32{ - Int32: staticAddressID, - Valid: true, - }, - ) - }) -} diff --git a/staticaddr/deposit/sql_store_test.go b/staticaddr/deposit/sql_store_test.go index 4045b2811..91ce88e38 100644 --- a/staticaddr/deposit/sql_store_test.go +++ b/staticaddr/deposit/sql_store_test.go @@ -3,6 +3,7 @@ package deposit import ( "context" "database/sql" + "reflect" "testing" "github.com/btcsuite/btcd/wire" @@ -16,12 +17,29 @@ import ( func TestCreateDepositRejectsUnpersistedAddress(t *testing.T) { store := NewSqlStore(nil) - deposit := &Deposit{ - AddressParams: &script.Parameters{}, + tests := []struct { + name string + params *script.Parameters + wantErr string + }{ + { + name: "missing parameters", + wantErr: "static address parameters must be set", + }, + { + name: "missing database ID", + params: &script.Parameters{}, + wantErr: "static address ID must be set", + }, } - err := store.CreateDeposit(context.Background(), deposit) - require.ErrorContains(t, err, "static address ID must be set") + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + deposit := &Deposit{AddressParams: testCase.params} + err := store.CreateDeposit(context.Background(), deposit) + require.ErrorContains(t, err, testCase.wantErr) + }) + } } func TestToDeposit(t *testing.T) { @@ -36,13 +54,13 @@ func TestToDeposit(t *testing.T) { tests := []struct { name string - row sqlc.AllDepositsRow + row sqlc.Deposit lastUpdate sqlc.DepositUpdate expectErr bool }{ { name: "fully valid data", - row: sqlc.AllDepositsRow{ + row: sqlc.Deposit{ DepositID: depositID[:], TxHash: txHash[:], Amount: 100000000, @@ -56,7 +74,7 @@ func TestToDeposit(t *testing.T) { }, { name: "fully valid data", - row: sqlc.AllDepositsRow{ + row: sqlc.Deposit{ DepositID: depositID[:], TxHash: txHash[:], Amount: 100000000, @@ -84,6 +102,139 @@ func TestToDeposit(t *testing.T) { } } +func TestToDepositWithAddress(t *testing.T) { + depositID, err := GetRandomDepositID() + require.NoError(t, err) + + txHash := wire.NewMsgTx(2).TxHash() + params := testAddressParameters(t, 7) + row := sqlc.AllDepositsWithAddressRow{ + DepositID: depositID[:], + TxHash: txHash[:], + Amount: 100_000, + ConfirmationHeight: 123, + StaticAddressID: sql.NullInt32{ + Int32: params.ID, + Valid: true, + }, + ClientPubkey: params.ClientPubkey.SerializeCompressed(), + ServerPubkey: params.ServerPubkey.SerializeCompressed(), + Expiry: sql.NullInt32{ + Int32: int32(params.Expiry), + Valid: true, + }, + ClientKeyFamily: sql.NullInt32{ + Int32: int32(params.KeyLocator.Family), + Valid: true, + }, + ClientKeyIndex: sql.NullInt32{ + Int32: int32(params.KeyLocator.Index), + Valid: true, + }, + Pkscript: params.PkScript, + ProtocolVersion: sql.NullInt32{ + Int32: int32(params.ProtocolVersion), + Valid: true, + }, + InitiationHeight: sql.NullInt32{ + Int32: params.InitiationHeight, + Valid: true, + }, + } + lastUpdate := sqlc.DepositUpdate{UpdateState: "completed"} + + result, err := ToDepositWithAddress(row, lastUpdate) + require.NoError(t, err) + require.NotNil(t, result.AddressParams) + require.Equal(t, params.ID, result.AddressParams.ID) + require.True(t, params.ClientPubkey.IsEqual( + result.AddressParams.ClientPubkey, + )) + require.True(t, params.ServerPubkey.IsEqual( + result.AddressParams.ServerPubkey, + )) + require.Equal(t, params.Expiry, result.AddressParams.Expiry) + require.Equal(t, params.PkScript, result.AddressParams.PkScript) + require.Equal(t, params.KeyLocator, result.AddressParams.KeyLocator) + require.Equal(t, params.ProtocolVersion, + result.AddressParams.ProtocolVersion) + require.Equal(t, params.InitiationHeight, + result.AddressParams.InitiationHeight) + + row.ClientPubkey = []byte{0x01} + result, err = ToDepositWithAddress(row, lastUpdate) + require.Error(t, err) + require.Nil(t, result) +} + +func TestDepositRowConvertersStayInSync(t *testing.T) { + t.Parallel() + + all := populatedSQLRow[sqlc.AllDepositsWithAddressRow](t) + require.Equal(t, expectedDepositRow(t, all), depositRowFromAll(all)) + + get := populatedSQLRow[sqlc.GetDepositWithAddressRow](t) + require.Equal(t, expectedDepositRow(t, get), depositRowFromGet(get)) + + outpoint := populatedSQLRow[sqlc.DepositForOutpointWithAddressRow](t) + require.Equal(t, expectedDepositRow(t, outpoint), + depositRowFromOutpoint(outpoint)) +} + +func populatedSQLRow[T any](t *testing.T) T { + t.Helper() + + var row T + value := reflect.ValueOf(&row).Elem() + for i := 0; i < value.NumField(); i++ { + field := value.Field(i) + n := int64(i + 1) + switch field.Type() { + case reflect.TypeOf([]byte{}): + field.SetBytes([]byte{byte(n)}) + + case reflect.TypeOf(int32(0)), reflect.TypeOf(int64(0)): + field.SetInt(n) + + case reflect.TypeOf(sql.NullString{}): + field.Set(reflect.ValueOf(sql.NullString{ + String: "set", + Valid: true, + })) + + case reflect.TypeOf(sql.NullInt32{}): + field.Set(reflect.ValueOf(sql.NullInt32{ + Int32: int32(n), + Valid: true, + })) + + default: + t.Fatalf("unsupported SQL row field %s", field.Type()) + } + } + + return row +} + +func expectedDepositRow(t *testing.T, sqlRow any) depositRow { + t.Helper() + + source := reflect.ValueOf(sqlRow) + target := reflect.ValueOf(&depositRow{}).Elem() + require.Equal(t, target.NumField()+1, source.NumField()) + require.Equal(t, "ID", source.Type().Field(0).Name) + + for i := 0; i < target.NumField(); i++ { + targetField := target.Type().Field(i) + sourceField := source.FieldByName(targetField.Name) + require.True(t, sourceField.IsValid(), targetField.Name) + require.Equal(t, targetField.Type, sourceField.Type()) + target.Field(i).Set(sourceField) + } + + return target.Interface().(depositRow) +} + func dummyHashBytes() []byte { return []byte{0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, diff --git a/staticaddr/loopin/deposit_swaphash_migration_test.go b/staticaddr/loopin/deposit_swaphash_migration_test.go index ab93a10d2..59f716f20 100644 --- a/staticaddr/loopin/deposit_swaphash_migration_test.go +++ b/staticaddr/loopin/deposit_swaphash_migration_test.go @@ -67,6 +67,9 @@ func TestDepositSwapHashMigration(t *testing.T) { 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x4d, }, } + addressParams := persistTestAddressParameters(t, ctxb, testDb.BaseDB) + d1.AddressParams = addressParams + d2.AddressParams = addressParams err := depositStore.CreateDeposit(ctxb, d1) require.NoError(t, err) diff --git a/staticaddr/loopin/selected_amount_migration_test.go b/staticaddr/loopin/selected_amount_migration_test.go index b68269fc0..62278eba3 100644 --- a/staticaddr/loopin/selected_amount_migration_test.go +++ b/staticaddr/loopin/selected_amount_migration_test.go @@ -61,6 +61,9 @@ func TestMigrateSelectedSwapAmount(t *testing.T) { 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x4d, }, } + addressParams := persistTestAddressParameters(t, ctxb, testDb.BaseDB) + d1.AddressParams = addressParams + d2.AddressParams = addressParams err := depositStore.CreateDeposit(ctxb, d1) require.NoError(t, err) diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index d5f712714..874ca94d2 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.AllDepositsRow{ + sqlcDeposit := sqlc.AllDepositsWithAddressRow{ 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{ @@ -617,7 +627,7 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, UpdateState: d.UpdateState.String, UpdateTimestamp: d.UpdateTimestamp.Time, } - deposit, err := deposit.ToDeposit( + deposit, err := deposit.ToDepositWithAddress( sqlcDeposit, sqlcDepositUpdate, ) if err != nil { diff --git a/staticaddr/loopin/sql_store_test.go b/staticaddr/loopin/sql_store_test.go index c08d940f2..0c29d4f32 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -10,9 +10,15 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/loopdb" + staticaddress "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/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" ) @@ -88,6 +94,10 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) { 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x50, }, } + addressParams := persistTestAddressParameters(t, ctxb, testDb.BaseDB) + for _, d := range []*deposit.Deposit{d1, d2, d3, d4} { + d.AddressParams = addressParams + } err := depositStore.CreateDeposit(ctxb, d1) require.NoError(t, err) @@ -212,6 +222,9 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) { require.Equal(t, d1.OutPoint, pendingDeposits[0].OutPoint) require.Equal(t, d1.Value, pendingDeposits[0].Value) require.Equal(t, deposit.LoopingIn, pendingDeposits[0].GetState()) + require.Equal(t, addressParams.ID, pendingDeposits[0].AddressParams.ID) + require.Equal(t, addressParams.PkScript, + pendingDeposits[0].AddressParams.PkScript) finalizedSwaps, err := swapStore.GetStaticAddressLoopInSwapsByStates(ctxb, FinalStates) require.NoError(t, err) @@ -292,6 +305,9 @@ func TestCreateLoopIn(t *testing.T) { 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x4d, }, } + addressParams := persistTestAddressParameters(t, ctx, testDb.BaseDB) + d1.AddressParams = addressParams + d2.AddressParams = addressParams err := depositStore.CreateDeposit(ctx, d1) require.NoError(t, err) @@ -504,6 +520,9 @@ func TestGetLoopInByHashOrdersDepositsBySnapshot(t *testing.T) { 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x4d, }, } + addressParams := persistTestAddressParameters(t, ctx, testDb.BaseDB) + d1.AddressParams = addressParams + d2.AddressParams = addressParams require.NoError(t, depositStore.CreateDeposit(ctx, d1)) require.NoError(t, depositStore.CreateDeposit(ctx, d2)) @@ -578,6 +597,7 @@ func TestGetLoopInByHashPreservesStoredDepositOutpoints(t *testing.T) { 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x41, }, } + d.AddressParams = persistTestAddressParameters(t, ctxb, testDb.BaseDB) require.NoError(t, depositStore.CreateDeposit(ctxb, d)) d.SetState(deposit.LoopingIn) @@ -616,3 +636,37 @@ func TestGetLoopInByHashPreservesStoredDepositOutpoints(t *testing.T) { require.Equal(t, currentOutpoint, storedSwap.Deposits[0].OutPoint) require.Equal(t, int64(42), storedSwap.Deposits[0].ConfirmationHeight) } + +func persistTestAddressParameters(t *testing.T, ctx context.Context, + db *loopdb.BaseDB) *script.Parameters { + + t.Helper() + + _, clientKey := test.CreateKey(31) + _, serverKey := test.CreateKey(32) + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, 144, clientKey, serverKey, + ) + require.NoError(t, err) + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + params := &script.Parameters{ + ClientPubkey: clientKey, + ServerPubkey: serverKey, + PkScript: pkScript, + Expiry: 144, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Index: 31, + }, + ProtocolVersion: version.ProtocolVersion_V0, + InitiationHeight: 100, + } + addressStore := staticaddress.NewSqlStore(db) + require.NoError(t, addressStore.CreateStaticAddress(ctx, params)) + params.ID, err = addressStore.GetStaticAddressID(ctx, pkScript) + require.NoError(t, err) + + return params +} diff --git a/staticaddr/withdraw/sql_store_test.go b/staticaddr/withdraw/sql_store_test.go index 5897f20ef..193088182 100644 --- a/staticaddr/withdraw/sql_store_test.go +++ b/staticaddr/withdraw/sql_store_test.go @@ -7,7 +7,14 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/loopdb" + staticaddress "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/swap" + "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/require" ) @@ -41,6 +48,11 @@ func TestSqlStore(t *testing.T) { 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x4d, }, } + addressParams := persistWithdrawalTestAddressParameters( + t, ctxb, testDb.BaseDB, + ) + d1.AddressParams = addressParams + d2.AddressParams = addressParams withdrawalTx := &wire.MsgTx{ Version: 2, @@ -99,3 +111,37 @@ func TestSqlStore(t *testing.T) { require.EqualValues(t, 100, withdrawals[0].ChangeAmount) require.EqualValues(t, 6, withdrawals[0].ConfirmationHeight) } + +func persistWithdrawalTestAddressParameters(t *testing.T, ctx context.Context, + db *loopdb.BaseDB) *script.Parameters { + + t.Helper() + + _, clientKey := test.CreateKey(41) + _, serverKey := test.CreateKey(42) + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, 144, clientKey, serverKey, + ) + require.NoError(t, err) + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + params := &script.Parameters{ + ClientPubkey: clientKey, + ServerPubkey: serverKey, + PkScript: pkScript, + Expiry: 144, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Index: 41, + }, + ProtocolVersion: version.ProtocolVersion_V0, + InitiationHeight: 100, + } + addressStore := staticaddress.NewSqlStore(db) + require.NoError(t, addressStore.CreateStaticAddress(ctx, params)) + params.ID, err = addressStore.GetStaticAddressID(ctx, pkScript) + require.NoError(t, err) + + return params +} From 2273c044dd1ef34802f6532c92fcf7a9c0bd496b Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 26 Aug 2026 14:06:32 +0200 Subject: [PATCH 5/6] staticaddr: harden deposit ownership recovery Quarantine ambiguous ownerless deposits without blocking manager recovery or reconciliation. Hydrate newly persisted address parameters with their database ID in the creation transaction. Share the Loop-In deposit row converter with the deposit store and cover it with the existing parity test. --- staticaddr/address/manager_test.go | 7 ++++ staticaddr/address/sql_store.go | 24 ++++++++++- staticaddr/address/sql_store_test.go | 44 ++++++++++++++++++++ staticaddr/deposit/manager.go | 19 +++++++++ staticaddr/deposit/manager_reconcile_test.go | 19 ++++----- staticaddr/deposit/manager_test.go | 18 ++++++++ staticaddr/deposit/sql_store.go | 37 ++++++++++++++++ staticaddr/deposit/sql_store_test.go | 22 +++++++++- staticaddr/loopin/sql_store.go | 36 +--------------- 9 files changed, 177 insertions(+), 49 deletions(-) create mode 100644 staticaddr/address/sql_store_test.go diff --git a/staticaddr/address/manager_test.go b/staticaddr/address/manager_test.go index b7bbf79ae..d88181d6c 100644 --- a/staticaddr/address/manager_test.go +++ b/staticaddr/address/manager_test.go @@ -132,6 +132,13 @@ func TestManager(t *testing.T) { // The expiry has to match. require.EqualValues(t, defaultExpiry, expiry) + + // A newly created address must be immediately usable as the owner of a + // deposit in this daemon session. In particular, its database-assigned + // ID must be available without requiring a restart and reload. + params, err := testContext.manager.GetStaticAddressParameters(ctxb) + require.NoError(t, err) + require.Positive(t, params.ID) } // TestNewAddressValidatesServerResponse tests that the untrusted diff --git a/staticaddr/address/sql_store.go b/staticaddr/address/sql_store.go index 16f113c44..9092aaec5 100644 --- a/staticaddr/address/sql_store.go +++ b/staticaddr/address/sql_store.go @@ -39,7 +39,29 @@ func (s *SqlStore) CreateStaticAddress(ctx context.Context, InitiationHeight: addrParams.InitiationHeight, } - return s.baseDB.Queries.CreateStaticAddress(ctx, createArgs) + var addressID int32 + err := s.baseDB.ExecTx(ctx, &loopdb.SqliteTxOptions{}, + func(q *sqlc.Queries) error { + err := q.CreateStaticAddress(ctx, createArgs) + if err != nil { + return err + } + + addressID, err = q.GetStaticAddressID( + ctx, addrParams.PkScript, + ) + return err + }) + if err != nil { + return err + } + + // Keep the in-memory parameters consistent with rows loaded back from + // the database. Callers can safely attach them to a deposit immediately + // after creating an address, without requiring a daemon restart. + addrParams.ID = addressID + + return nil } // GetStaticAddressID retrieves the database ID for a static address script. diff --git a/staticaddr/address/sql_store_test.go b/staticaddr/address/sql_store_test.go new file mode 100644 index 000000000..2508015c2 --- /dev/null +++ b/staticaddr/address/sql_store_test.go @@ -0,0 +1,44 @@ +package address + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// TestCreateStaticAddressSetsID verifies that address parameters are ready to +// own deposits immediately after they are persisted. +func TestCreateStaticAddressSetsID(t *testing.T) { + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + params := &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PkScript: []byte{0x51}, + Expiry: 144, + KeyLocator: keychain.KeyLocator{ + Family: 1, + Index: 2, + }, + ProtocolVersion: version.ProtocolVersion_V0, + InitiationHeight: 100, + } + + db := loopdb.NewTestDB(t) + store := NewSqlStore(db.BaseDB) + require.NoError(t, store.CreateStaticAddress(t.Context(), params)) + require.Positive(t, params.ID) + + storedParams, err := store.GetAllStaticAddresses(t.Context()) + require.NoError(t, err) + require.Len(t, storedParams, 1) + require.Equal(t, params.ID, storedParams[0].ID) +} diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 62523c7dd..ef8928e7a 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -231,6 +231,18 @@ func (m *Manager) recoverDeposits(ctx context.Context) error { continue } + // Deposits without an owning static address are deliberately left + // unassigned by the ownership migration when that ownership is + // ambiguous. Keep these deposits in the known-deposit set so they + // aren't rediscovered, but don't let one ambiguous legacy row stop + // recovery of every other deposit. + if d.AddressParams == nil { + log.Warnf("Quarantining deposit %x: static address "+ + "ownership is missing", d.ID) + + continue + } + log.Debugf("Recovering deposit %x", d.ID) // Create a state machine for a given deposit. @@ -526,6 +538,13 @@ func (m *Manager) syncActiveDeposits(ctx context.Context, continue } + // Ownerless legacy deposits are quarantined during recovery. + // Avoid trying to reactivate them on every reconciliation, + // which would otherwise make all future reconciliations fail. + if deposit.AddressParams == nil { + continue + } + toActivate = append(toActivate, deposit) } diff --git a/staticaddr/deposit/manager_reconcile_test.go b/staticaddr/deposit/manager_reconcile_test.go index 2a04fc0fc..e9065a7ec 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" @@ -531,10 +530,13 @@ func TestReconcileDepositsKeepsInactiveOnFSMStartFailure(t *testing.T) { Index: 5, } + addressParams := testAddressParameters(t, 5) + addressParams.ProtocolVersion = 999 deposit := &Deposit{ OutPoint: outpoint, Value: btcutil.Amount(100_000), ConfirmationHeight: 77, + AddressParams: addressParams, } deposit.SetState(Deposited) @@ -548,10 +550,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 @@ -596,9 +594,12 @@ func TestReconcileDepositsDeactivatesBeforeActivationFailure(t *testing.T) { Index: 6, } + addressParams := testAddressParameters(t, 6) + addressParams.ProtocolVersion = 999 visibleDeposit := &Deposit{ - OutPoint: visibleOutpoint, - Value: btcutil.Amount(100_000), + OutPoint: visibleOutpoint, + Value: btcutil.Amount(100_000), + AddressParams: addressParams, } visibleDeposit.SetState(Deposited) @@ -618,10 +619,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), diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 64184ff28..0782d8608 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -385,6 +385,22 @@ func TestManagerReplaysStartupBlockToRecoveredDeposits(t *testing.T) { } } +// TestManagerQuarantinesOwnerlessDeposit verifies that an ambiguous legacy +// deposit left without a static-address owner does not prevent the deposit +// manager from starting or reconciling the remaining wallet view. +func TestManagerQuarantinesOwnerlessDeposit(t *testing.T) { + testContext := newManagerTestContext(t) + testContext.storedDeposits[0].AddressParams = nil + + require.NoError(t, testContext.manager.recoverDeposits(t.Context())) + require.NoError(t, testContext.manager.reconcileDeposits(t.Context())) + + testContext.manager.mu.Lock() + require.Len(t, testContext.manager.deposits, 1) + require.Empty(t, testContext.manager.activeDeposits) + testContext.manager.mu.Unlock() +} + // TestManagerSkipsExpiryNotificationOnReconcileFailure verifies that deposit // FSMs cannot make an expiry decision from stale confirmation data when wallet // reconciliation fails at startup or while processing a later block. @@ -492,6 +508,7 @@ type ManagerTestContext struct { confErrChan chan error blockChan chan int32 blockErrChan chan error + storedDeposits []*Deposit } // newManagerTestContext creates a new test context for the reservation manager. @@ -592,6 +609,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { confErrChan: confErrChan, blockChan: blockChan, blockErrChan: blockErrChan, + storedDeposits: storedDeposits, } staticAddress, err := script.NewStaticAddress( diff --git a/staticaddr/deposit/sql_store.go b/staticaddr/deposit/sql_store.go index 46301ef73..990227b70 100644 --- a/staticaddr/deposit/sql_store.go +++ b/staticaddr/deposit/sql_store.go @@ -282,6 +282,20 @@ func ToDepositWithAddress(row sqlc.AllDepositsWithAddressRow, return toDeposit(depositRowFromAll(row), lastUpdate) } +// ToDepositForSwapHash converts the joined deposit row returned while +// recovering a static-address Loop-In swap. Keeping this conversion beside the +// other deposit-row converters prevents the Loop-In store from maintaining a +// separate hand-copy of every deposit and static-address field. +func ToDepositForSwapHash(row sqlc.DepositsForSwapHashRow) (*Deposit, error) { + lastUpdate := sqlc.DepositUpdate{ + DepositID: row.DepositID, + UpdateState: row.UpdateState.String, + UpdateTimestamp: row.UpdateTimestamp.Time, + } + + return toDeposit(depositRowFromSwapHash(row), lastUpdate) +} + type depositRow struct { DepositID []byte TxHash []byte @@ -340,6 +354,29 @@ func depositRowFromAll(row sqlc.AllDepositsWithAddressRow) depositRow { } } +func depositRowFromSwapHash(row sqlc.DepositsForSwapHashRow) 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.GetDepositWithAddressRow) depositRow { return depositRow{ DepositID: row.DepositID, diff --git a/staticaddr/deposit/sql_store_test.go b/staticaddr/deposit/sql_store_test.go index 91ce88e38..0b1651687 100644 --- a/staticaddr/deposit/sql_store_test.go +++ b/staticaddr/deposit/sql_store_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "reflect" "testing" + "time" "github.com/btcsuite/btcd/wire" "github.com/jackc/pgx/v5" @@ -179,6 +180,11 @@ func TestDepositRowConvertersStayInSync(t *testing.T) { outpoint := populatedSQLRow[sqlc.DepositForOutpointWithAddressRow](t) require.Equal(t, expectedDepositRow(t, outpoint), depositRowFromOutpoint(outpoint)) + + swapHash := populatedSQLRow[sqlc.DepositsForSwapHashRow](t) + require.Equal(t, + expectedDepositRow(t, swapHash, "UpdateState", "UpdateTimestamp"), + depositRowFromSwapHash(swapHash)) } func populatedSQLRow[T any](t *testing.T) T { @@ -208,6 +214,12 @@ func populatedSQLRow[T any](t *testing.T) T { Valid: true, })) + case reflect.TypeOf(sql.NullTime{}): + field.Set(reflect.ValueOf(sql.NullTime{ + Time: time.Unix(n, 0).UTC(), + Valid: true, + })) + default: t.Fatalf("unsupported SQL row field %s", field.Type()) } @@ -216,13 +228,19 @@ func populatedSQLRow[T any](t *testing.T) T { return row } -func expectedDepositRow(t *testing.T, sqlRow any) depositRow { +func expectedDepositRow(t *testing.T, sqlRow any, + additionalFields ...string) depositRow { + t.Helper() source := reflect.ValueOf(sqlRow) target := reflect.ValueOf(&depositRow{}).Elem() - require.Equal(t, target.NumField()+1, source.NumField()) + require.Equal(t, target.NumField()+1+len(additionalFields), + source.NumField()) require.Equal(t, "ID", source.Type().Field(0).Name) + for _, fieldName := range additionalFields { + require.True(t, source.FieldByName(fieldName).IsValid(), fieldName) + } for i := 0; i < target.NumField(); i++ { targetField := target.Type().Field(i) diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index 874ca94d2..673d531e9 100644 --- a/staticaddr/loopin/sql_store.go +++ b/staticaddr/loopin/sql_store.go @@ -595,41 +595,7 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, depositList := make([]*deposit.Deposit, 0, len(deposits)) for _, d := range deposits { - id := deposit.ID{} - err = id.FromByteSlice(d.DepositID) - if err != nil { - return nil, err - } - - sqlcDeposit := sqlc.AllDepositsWithAddressRow{ - DepositID: id[:], - TxHash: d.TxHash, - Amount: d.Amount, - OutIndex: d.OutIndex, - ConfirmationHeight: d.ConfirmationHeight, - 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{ - DepositID: id[:], - UpdateState: d.UpdateState.String, - UpdateTimestamp: d.UpdateTimestamp.Time, - } - deposit, err := deposit.ToDepositWithAddress( - sqlcDeposit, sqlcDepositUpdate, - ) + deposit, err := deposit.ToDepositForSwapHash(d) if err != nil { return nil, err } From dc460df208e2bbe5242a626bd12f26261a4109e1 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 26 Aug 2026 14:19:16 +0200 Subject: [PATCH 6/6] staticaddr: fix ownership review lint --- staticaddr/deposit/manager_test.go | 5 ----- staticaddr/deposit/sql_store_test.go | 10 +++++----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 0782d8608..ae6cae797 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -2,7 +2,6 @@ package deposit import ( "context" - "encoding/hex" "errors" "testing" "time" @@ -25,10 +24,6 @@ import ( ) var ( - defaultServerPubkeyBytes, _ = hex.DecodeString("021c97a90a411ff2b10dc2a8e32de2f29d2fa49d41bfbb52bd416e460db0747d0d") - - defaultServerPubkey, _ = btcec.ParsePubKey(defaultServerPubkeyBytes) - defaultExpiry = uint32(100) defaultDepositConfirmations = uint32(3) diff --git a/staticaddr/deposit/sql_store_test.go b/staticaddr/deposit/sql_store_test.go index 0b1651687..06bb743be 100644 --- a/staticaddr/deposit/sql_store_test.go +++ b/staticaddr/deposit/sql_store_test.go @@ -196,25 +196,25 @@ func populatedSQLRow[T any](t *testing.T) T { field := value.Field(i) n := int64(i + 1) switch field.Type() { - case reflect.TypeOf([]byte{}): + case reflect.TypeFor[[]byte](): field.SetBytes([]byte{byte(n)}) - case reflect.TypeOf(int32(0)), reflect.TypeOf(int64(0)): + case reflect.TypeFor[int32](), reflect.TypeFor[int64](): field.SetInt(n) - case reflect.TypeOf(sql.NullString{}): + case reflect.TypeFor[sql.NullString](): field.Set(reflect.ValueOf(sql.NullString{ String: "set", Valid: true, })) - case reflect.TypeOf(sql.NullInt32{}): + case reflect.TypeFor[sql.NullInt32](): field.Set(reflect.ValueOf(sql.NullInt32{ Int32: int32(n), Valid: true, })) - case reflect.TypeOf(sql.NullTime{}): + case reflect.TypeFor[sql.NullTime](): field.Set(reflect.ValueOf(sql.NullTime{ Time: time.Unix(n, 0).UTC(), Valid: true,