staticaddr/deposit: restore owning address parameters

Join each selected deposit with its persisted static-address row during
loop-in recovery. Hydrate legacy rows as needed so restored swaps retain
the scripts and key locators required for signing.
This commit is contained in:
Slyghtning 2026-05-06 15:02:09 +02:00
parent 1b212fa105
commit b8157ef901
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
9 changed files with 348 additions and 64 deletions

View file

@ -8,6 +8,7 @@ import (
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop/looprpc"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/loopin"
"github.com/stretchr/testify/require"
@ -196,6 +197,9 @@ func TestWarningDepositSelectionMatchesLoopInSelection(t *testing.T) {
OutPoint: outpoint,
Value: btcutil.Amount(fixture.value),
ConfirmationHeight: fixture.confirmationHeight,
AddressParams: &address.Parameters{
Expiry: csvExpiry,
},
})
}
@ -204,8 +208,7 @@ func TestWarningDepositSelectionMatchesLoopInSelection(t *testing.T) {
)
loopInSelected, err := loopin.SelectDeposits(
btcutil.Amount(targetAmount), loopInDeposits, csvExpiry,
blockHeight,
btcutil.Amount(targetAmount), loopInDeposits, blockHeight,
)
require.NoError(t, err)

View file

@ -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

View file

@ -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 {

View file

@ -13,6 +13,7 @@ import (
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/lnwallet"
)
@ -69,8 +70,8 @@ type Manager struct {
// mu guards access to the activeDeposits map.
mu sync.Mutex
// reconcileMu serializes deposit reconciliation so new deposits are
// discovered and retained exactly once per outpoint.
// reconcileMu serializes startup recovery and deposit reconciliation so
// new deposits are discovered and retained exactly once per outpoint.
reconcileMu sync.Mutex
// activeDeposits contains all the active static address outputs.
@ -213,6 +214,9 @@ func (m *Manager) notifyActiveDeposits(ctx context.Context,
// recoverDeposits recovers static address parameters, previous deposits and
// state machines from the database and starts the deposit notifier.
func (m *Manager) recoverDeposits(ctx context.Context) error {
m.reconcileMu.Lock()
defer m.reconcileMu.Unlock()
log.Infof("Recovering static address parameters and deposits...")
// Recover deposits.
@ -222,6 +226,11 @@ func (m *Manager) recoverDeposits(ctx context.Context) error {
}
for i, d := range deposits {
err = m.hydrateLegacyDepositAddressParams(ctx, d)
if err != nil {
return err
}
m.deposits[d.OutPoint] = deposits[i]
// If the current deposit is final it wasn't active when we
@ -258,6 +267,66 @@ func (m *Manager) recoverDeposits(ctx context.Context) error {
return nil
}
// hydrateLegacyDepositAddressParams fills in address parameters for deposits
// that predate the durable deposit-to-static-address link. Those deposits all
// belonged to the legacy/root static address, so the legacy address manager
// lookup preserves the behavior that existed before multi-address support.
func (m *Manager) hydrateLegacyDepositAddressParams(ctx context.Context,
deposits ...*Deposit) error {
needsHydration := false
for _, d := range deposits {
if d != nil && d.AddressParams == nil {
needsHydration = true
break
}
}
if !needsHydration {
return nil
}
if m.cfg == nil || m.cfg.AddressManager == nil {
return nil
}
var legacyParams *address.Parameters
for _, d := range deposits {
if d == nil || d.AddressParams != nil {
continue
}
if legacyParams == nil {
params, err := m.cfg.AddressManager.
GetStaticAddressParameters(ctx)
if err != nil {
return fmt.Errorf("unable to load legacy "+
"static address parameters for deposit %v: %w",
d.OutPoint, err)
}
if params == nil {
return fmt.Errorf("missing legacy static address "+
"parameters for deposit %v", d.OutPoint)
}
if params.ID <= 0 {
params.ID, err = m.cfg.AddressManager.
GetStaticAddressID(ctx, params.PkScript)
if err != nil {
return fmt.Errorf("unable to load legacy "+
"static address ID for deposit %v: %w",
d.OutPoint, err)
}
}
legacyParams = params
}
d.AddressParams = legacyParams
}
return nil
}
// pollDeposits periodically polls for new deposits to our static address. This
// complements the block-driven reconciliation in the main event loop: while new
// blocks trigger reconcileDeposits to promptly detect confirmations, the ticker
@ -805,7 +874,17 @@ func (m *Manager) removeActiveDeposit(outpoint wire.OutPoint) {
// GetAllDeposits returns all known deposits from the database.
func (m *Manager) GetAllDeposits(ctx context.Context) ([]*Deposit, error) {
return m.cfg.Store.AllDeposits(ctx)
deposits, err := m.cfg.Store.AllDeposits(ctx)
if err != nil {
return nil, err
}
err = m.hydrateLegacyDepositAddressParams(ctx, deposits...)
if err != nil {
return nil, err
}
return deposits, nil
}
// GetVisibleDeposits returns deposits that should be exposed through normal
@ -820,6 +899,11 @@ func (m *Manager) GetVisibleDeposits(ctx context.Context) ([]*Deposit, error) {
return nil, err
}
err = m.hydrateLegacyDepositAddressParams(ctx, deposits...)
if err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
@ -896,6 +980,11 @@ func (m *Manager) DepositsForOutpoints(ctx context.Context,
return nil, err
}
err = m.hydrateLegacyDepositAddressParams(ctx, deposit)
if err != nil {
return nil, err
}
deposits = append(deposits, deposit)
}

View file

@ -13,6 +13,7 @@ import (
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/staticaddr/version"
"github.com/lightninglabs/loop/test"
@ -41,8 +42,15 @@ func TestReconcileDepositsSerialized(t *testing.T) {
"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"))
"GetParameters", mock.Anything,
).Return(&address.Parameters{
ID: 1,
ClientPubkey: defaultServerPubkey,
ServerPubkey: defaultServerPubkey,
Expiry: defaultExpiry,
PkScript: utxo.PkScript,
ProtocolVersion: 999,
})
mockStore := new(mockStore)
var createCalls atomic.Int32
@ -137,8 +145,15 @@ func TestReconcileConfirmedDepositUsesCurrentHeight(t *testing.T) {
"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"))
"GetParameters", mock.Anything,
).Return(&address.Parameters{
ID: 1,
ClientPubkey: defaultServerPubkey,
ServerPubkey: defaultServerPubkey,
Expiry: defaultExpiry,
PkScript: utxo.PkScript,
ProtocolVersion: 999,
})
mockStore := new(mockStore)
mockStore.On(
@ -471,6 +486,12 @@ func TestReconcileDepositsReactivatesReappearedDeposit(t *testing.T) {
OutPoint: outpoint,
Value: btcutil.Amount(100_000),
ConfirmationHeight: 77,
AddressParams: &address.Parameters{
ClientPubkey: defaultServerPubkey,
ServerPubkey: defaultServerPubkey,
Expiry: defaultExpiry,
ProtocolVersion: version.ProtocolVersion_V0,
},
}
deposit.SetState(Deposited)

View file

@ -12,6 +12,7 @@ import (
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/loop/swapserverrpc"
@ -524,6 +525,96 @@ func TestManagerSkipsExpiryNotificationOnReconcileFailure(t *testing.T) {
}
}
func TestRecoverDepositsKeepsSpentWithdrawing(t *testing.T) {
ctx := context.Background()
id, err := GetRandomDepositID()
require.NoError(t, err)
storedDeposit := &Deposit{
ID: id,
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{2},
Index: 2,
},
state: Withdrawing,
Value: btcutil.Amount(100000),
ConfirmationHeight: 42,
}
testContext := newManagerTestContextWithStoredDeposits(
t, []*Deposit{storedDeposit}, nil,
)
err = testContext.manager.recoverDeposits(ctx)
require.NoError(t, err)
deposits, err := testContext.manager.GetActiveDepositsInState(Withdrawing)
require.NoError(t, err)
require.Len(t, deposits, 1)
require.Equal(t, storedDeposit.OutPoint, deposits[0].OutPoint)
}
func TestRecoverDepositsHydratesLegacyAddressParams(t *testing.T) {
ctx := context.Background()
id, err := GetRandomDepositID()
require.NoError(t, err)
storedDeposit := &Deposit{
ID: id,
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{3},
Index: 3,
},
state: Deposited,
Value: btcutil.Amount(100000),
ConfirmationHeight: 42,
TimeOutSweepPkScript: []byte{0x42, 0x21, 0x69},
}
testContext := newManagerTestContextWithStoredDeposits(
t, []*Deposit{storedDeposit}, nil,
)
storedDeposit.AddressParams = nil
err = testContext.manager.recoverDeposits(ctx)
require.NoError(t, err)
require.NotNil(t, storedDeposit.AddressParams)
require.NotZero(t, storedDeposit.AddressParams.ID)
}
func TestGetAllDepositsHydratesLegacyAddressParams(t *testing.T) {
ctx := context.Background()
id, err := GetRandomDepositID()
require.NoError(t, err)
storedDeposit := &Deposit{
ID: id,
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{4},
Index: 4,
},
state: Withdrawn,
Value: btcutil.Amount(100000),
ConfirmationHeight: 42,
}
testContext := newManagerTestContextWithStoredDeposits(
t, []*Deposit{storedDeposit}, nil,
)
storedDeposit.AddressParams = nil
deposits, err := testContext.manager.GetAllDeposits(ctx)
require.NoError(t, err)
require.Len(t, deposits, 1)
require.NotNil(t, deposits[0].AddressParams)
require.NotZero(t, deposits[0].AddressParams.ID)
}
// ManagerTestContext is a helper struct that contains all the necessary
// components to test the reservation manager.
type ManagerTestContext struct {
@ -540,6 +631,39 @@ type ManagerTestContext struct {
// newManagerTestContext creates a new test context for the reservation manager.
func newManagerTestContext(t *testing.T) *ManagerTestContext {
ID, err := GetRandomDepositID()
require.NoError(t, err)
utxo := &lnwallet.Utxo{
AddressType: lnwallet.TaprootPubkey,
Value: btcutil.Amount(100000),
Confirmations: int64(defaultDepositConfirmations),
PkScript: []byte("pkscript"),
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{},
Index: 0xffffffff,
},
}
storedDeposits := []*Deposit{
{
ID: ID,
state: Deposited,
OutPoint: utxo.OutPoint,
Value: utxo.Value,
ConfirmationHeight: 3,
TimeOutSweepPkScript: []byte{0x42, 0x21, 0x69},
},
}
return newManagerTestContextWithStoredDeposits(
t, storedDeposits, []*lnwallet.Utxo{utxo},
)
}
func newManagerTestContextWithStoredDeposits(t *testing.T,
storedDeposits []*Deposit, utxos []*lnwallet.Utxo) *ManagerTestContext {
mockLnd := test.NewMockLnd()
lndContext := test.NewContext(t, mockLnd)
@ -552,29 +676,6 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext {
blockChan := make(chan int32)
blockErrChan := make(chan error)
ID, err := GetRandomDepositID()
utxo := &lnwallet.Utxo{
AddressType: lnwallet.TaprootPubkey,
Value: btcutil.Amount(100000),
Confirmations: int64(defaultDepositConfirmations),
PkScript: []byte("pkscript"),
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{},
Index: 0xffffffff,
},
}
require.NoError(t, err)
storedDeposits := []*Deposit{
{
ID: ID,
state: Deposited,
OutPoint: utxo.OutPoint,
Value: utxo.Value,
ConfirmationHeight: 3,
TimeOutSweepPkScript: []byte{0x42, 0x21, 0x69},
},
}
mockStore.On(
"AllDeposits", mock.Anything,
).Return(storedDeposits, nil)
@ -583,17 +684,29 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext {
"UpdateDeposit", mock.Anything, mock.Anything,
).Return(nil)
staticAddress, addrParams := generateStaticAddress(
context.Background(), mockLnd, lndContext.T,
)
for _, storedDeposit := range storedDeposits {
if storedDeposit.AddressParams == nil {
storedDeposit.AddressParams = addrParams
}
}
var manager *Manager
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return(&script.Parameters{
Expiry: defaultExpiry,
}, nil)
).Return(addrParams, nil)
mockAddressManager.On(
"ListUnspent", mock.Anything, mock.Anything, mock.Anything,
).Return(func() []*lnwallet.Utxo {
currentUtxo := *utxo
if len(utxos) != 1 {
return utxos
}
currentUtxo := *utxos[0]
currentHeight := manager.currentHeight.Load()
if currentHeight < defaultDepositConfirmations {
currentUtxo.Confirmations = 0
@ -638,9 +751,6 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext {
blockErrChan: blockErrChan,
}
staticAddress := generateStaticAddress(
context.Background(), testContext,
)
mockAddressManager.On(
"GetStaticAddress", mock.Anything,
).Return(staticAddress, nil)
@ -648,19 +758,30 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext {
return testContext
}
func generateStaticAddress(ctx context.Context,
t *ManagerTestContext) *script.StaticAddress {
func generateStaticAddress(ctx context.Context, mockLnd *test.LndMockServices,
t *testing.T) (*script.StaticAddress, *address.Parameters) {
keyDescriptor, err := t.mockLnd.WalletKit.DeriveNextKey(
keyDescriptor, err := mockLnd.WalletKit.DeriveNextKey(
ctx, swap.StaticAddressKeyFamily,
)
require.NoError(t.context.T, err)
require.NoError(t, err)
staticAddress, err := script.NewStaticAddress(
input.MuSig2Version100RC2, int64(defaultExpiry),
keyDescriptor.PubKey, defaultServerPubkey,
)
require.NoError(t.context.T, err)
require.NoError(t, err)
return staticAddress
pkScript, err := staticAddress.StaticAddressScript()
require.NoError(t, err)
return staticAddress, &address.Parameters{
ID: 1,
ClientPubkey: keyDescriptor.PubKey,
ServerPubkey: defaultServerPubkey,
Expiry: defaultExpiry,
PkScript: pkScript,
KeyLocator: keyDescriptor.KeyLocator,
ProtocolVersion: 0,
}
}

View file

@ -678,19 +678,8 @@ func (m *Manager) initiateLoopIn(ctx context.Context,
"deposits: %w", err)
}
// TODO(hieblmi): add params to deposit for multi-address
// support.
params, err := m.cfg.AddressManager.GetStaticAddressParameters(
ctx,
)
if err != nil {
return nil, fmt.Errorf("unable to retrieve static "+
"address parameters: %w", err)
}
selectedDeposits, err = SelectDeposits(
req.SelectedAmount, allDeposits, params.Expiry,
m.currentHeight.Load(),
req.SelectedAmount, allDeposits, m.currentHeight.Load(),
)
if err != nil {
return nil, fmt.Errorf("unable to select deposits: %w",
@ -870,15 +859,21 @@ func (m *Manager) activeDepositsForLoopIn(loopIn *StaticAddressLoopIn) (
// leaving a dust change. It returns an error if the sum of deposits minus dust
// is less than the requested amount.
func SelectDeposits(targetAmount btcutil.Amount,
unfilteredDeposits []*deposit.Deposit, csvExpiry uint32,
blockHeight uint32) ([]*deposit.Deposit, error) {
unfilteredDeposits []*deposit.Deposit, blockHeight uint32) (
[]*deposit.Deposit, error) {
// Filter out deposits that are too close to expiry to be swapped.
var deposits []*deposit.Deposit
for _, d := range unfilteredDeposits {
confirmationHeight := d.GetConfirmationHeight()
if d.AddressParams == nil {
return nil, fmt.Errorf("missing static address parameters "+
"for deposit %s", d.OutPoint.String())
}
if !IsSwappable(
uint32(confirmationHeight), blockHeight, csvExpiry,
uint32(confirmationHeight), blockHeight,
d.AddressParams.Expiry,
) {
log.Debugf("Skipping deposit %s as it expires before "+
@ -905,11 +900,11 @@ func SelectDeposits(targetAmount btcutil.Amount,
if deposits[i].Value == deposits[j].Value {
iExp := blocksUntilDepositExpiry(
uint32(iConfirmationHeight), blockHeight,
csvExpiry,
deposits[i].AddressParams.Expiry,
)
jExp := blocksUntilDepositExpiry(
uint32(jConfirmationHeight), blockHeight,
csvExpiry,
deposits[j].AddressParams.Expiry,
)
return iExp < jExp

View file

@ -14,6 +14,7 @@ import (
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/labels"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/swap"
@ -193,9 +194,11 @@ func TestSelectDeposits(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
setTestDepositParams(tc.deposits, tc.csvExpiry)
setTestDepositParams(tc.expected, tc.csvExpiry)
selectedDeposits, err := SelectDeposits(
tc.targetValue, tc.deposits, tc.csvExpiry,
tc.blockHeight,
tc.targetValue, tc.deposits, tc.blockHeight,
)
if tc.expectedErr == "" {
require.NoError(t, err)
@ -417,6 +420,14 @@ func TestGetAllSwapsPreservesStoreDeposits(t *testing.T) {
require.Equal(t, []*deposit.Deposit{currentDeposit}, swaps[0].Deposits)
}
func setTestDepositParams(deposits []*deposit.Deposit, expiry uint32) {
for _, d := range deposits {
d.AddressParams = &address.Parameters{
Expiry: expiry,
}
}
}
// TestIsSwappableUnconfirmed checks that an unconfirmed deposit is considered
// swappable because its CSV timeout has not started yet.
func TestIsSwappableUnconfirmed(t *testing.T) {

View file

@ -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{