From 5718f111623962254dce1f438caca7bcf2f6c626 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 2 Jun 2025 15:49:03 +0200 Subject: [PATCH] staticaddr: store historic withdrawal info --- staticaddr/withdraw/interface.go | 12 ++ staticaddr/withdraw/manager.go | 44 +++-- staticaddr/withdraw/sql_store.go | 221 ++++++++++++++++++++++++++ staticaddr/withdraw/sql_store_test.go | 101 ++++++++++++ staticaddr/withdraw/withdrawal.go | 68 ++++++++ 5 files changed, 437 insertions(+), 9 deletions(-) create mode 100644 staticaddr/withdraw/sql_store.go create mode 100644 staticaddr/withdraw/sql_store_test.go create mode 100644 staticaddr/withdraw/withdrawal.go diff --git a/staticaddr/withdraw/interface.go b/staticaddr/withdraw/interface.go index 3131d967..dd6587e8 100644 --- a/staticaddr/withdraw/interface.go +++ b/staticaddr/withdraw/interface.go @@ -11,6 +11,18 @@ import ( "github.com/lightningnetwork/lnd/lnwallet" ) +// Store is the database interface that is used to store and retrieve +// static address withdrawals. +type Store interface { + // CreateWithdrawal inserts a withdrawal into the store. + CreateWithdrawal(ctx context.Context, tx *wire.MsgTx, + confirmationHeight uint32, deposits []*deposit.Deposit, + changePkScript []byte) error + + // GetAllWithdrawals retrieves all withdrawals. + GetAllWithdrawals(ctx context.Context) ([]Withdrawal, error) +} + // AddressManager handles fetching of address parameters. type AddressManager interface { // GetStaticAddressParameters returns the static address parameters. diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 329bfef1..f8ff8a85 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -82,6 +82,10 @@ type ManagerConfig struct { // Signer is the signer client that is used to sign transactions. Signer lndclient.SignerClient + + // Store is the store that is used to persist the finalized withdrawal + // transactions. + Store *SqlStore } // newWithdrawalRequest is used to send withdrawal request to the manager main @@ -401,6 +405,13 @@ func (m *Manager) WithdrawDeposits(ctx context.Context, // republished in case of a fee bump, it suffices if only one spent // notifier is run. if allDeposited { + // Persist info about the finalized withdrawal. + err = m.cfg.Store.CreateWithdrawal(ctx, deposits) + if err != nil { + log.Errorf("Error persisting "+ + "withdrawal: %v", err) + } + err = m.handleWithdrawal( ctx, deposits, finalizedTx.TxHash(), withdrawalPkScript, ) @@ -592,9 +603,7 @@ func (m *Manager) handleWithdrawal(ctx context.Context, deposits []*deposit.Deposit, txHash chainhash.Hash, withdrawalPkscript []byte) error { - addrParams, err := m.cfg.AddressManager.GetStaticAddressParameters( - ctx, - ) + addrParams, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) if err != nil { log.Errorf("error retrieving address params %w", err) @@ -609,19 +618,20 @@ func (m *Manager) handleWithdrawal(ctx context.Context, go func() { select { - case <-spentChan: + case spentTx := <-spentChan: + spendingHeight := uint32(spentTx.SpendingHeight) // If the transaction received one confirmation, we // ensure re-org safety by waiting for some more // confirmations. var confChan chan *chainntnfs.TxConfirmation confChan, errChan, err = m.cfg.ChainNotifier.RegisterConfirmationsNtfn( - ctx, &txHash, withdrawalPkscript, - MinConfs, + ctx, spentTx.SpenderTxHash, + withdrawalPkscript, MinConfs, int32(m.initiationHeight.Load()), ) select { - case <-confChan: + case tx := <-confChan: err = m.cfg.DepositManager.TransitionDeposits( ctx, deposits, deposit.OnWithdrawn, deposit.Withdrawn, @@ -631,12 +641,23 @@ func (m *Manager) handleWithdrawal(ctx context.Context, "deposits: %v", err) } - // Remove the withdrawal tx from the active withdrawals - // to stop republishing it on block arrivals. + // Remove the withdrawal tx from the active + // withdrawals to stop republishing it on block + // arrivals. m.mu.Lock() delete(m.finalizedWithdrawalTxns, txHash) m.mu.Unlock() + // Persist info about the finalized withdrawal. + err = m.cfg.Store.UpdateWithdrawal( + ctx, deposits, tx.Tx, spendingHeight, + addrParams.PkScript, + ) + if err != nil { + log.Errorf("Error persisting "+ + "withdrawal: %v", err) + } + case err := <-errChan: log.Errorf("Error waiting for confirmation: %v", err) @@ -1116,3 +1137,8 @@ func (m *Manager) DeliverWithdrawalRequest(ctx context.Context, "for withdrawal response") } } + +// GetAllWithdrawals returns all finalized withdrawals from the store. +func (m *Manager) GetAllWithdrawals(ctx context.Context) ([]Withdrawal, error) { + return m.cfg.Store.GetAllWithdrawals(ctx) +} diff --git a/staticaddr/withdraw/sql_store.go b/staticaddr/withdraw/sql_store.go new file mode 100644 index 00000000..df6f27f6 --- /dev/null +++ b/staticaddr/withdraw/sql_store.go @@ -0,0 +1,221 @@ +package withdraw + +import ( + "bytes" + "context" + "database/sql" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/loopdb/sqlc" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightningnetwork/lnd/clock" +) + +type Querier interface { + // CreateWithdrawal inserts a new withdrawal. + CreateWithdrawal(ctx context.Context, + arg sqlc.CreateWithdrawalParams) error + + // UpdateWithdrawal updates a withdrawal with confirmation parameters. + UpdateWithdrawal(ctx context.Context, + arg sqlc.UpdateWithdrawalParams) error + + // GetWithdrawalIDByDepositID retrieves the withdrawal ID associated + // with a given deposit ID. + GetWithdrawalIDByDepositID(ctx context.Context, depositID []byte) ( + []byte, error) + + // CreateWithdrawalDeposit links withdrawal to deposits. + CreateWithdrawalDeposit(ctx context.Context, + arg sqlc.CreateWithdrawalDepositParams) error + + // GetWithdrawalDeposits retrieves the deposit IDs associated with a + // withdrawal. + GetWithdrawalDeposits(ctx context.Context, withdrawalID []byte) ( + [][]byte, error) + + // GetAllWithdrawals retrieves all withdrawals from the database. + GetAllWithdrawals(ctx context.Context) ([]sqlc.Withdrawal, error) +} + +// BaseDB is the interface that contains all the queries generated by sqlc for +// the static_address_swaps table and transaction functionality. +type BaseDB interface { + Querier + + // ExecTx allows for executing a function in the context of a database + // transaction. + ExecTx(ctx context.Context, txOptions loopdb.TxOptions, + txBody func(Querier) error) error +} + +// SqlStore is the backing store for static address withdrawals. +type SqlStore struct { + baseDB BaseDB + depositStore deposit.Store + clock clock.Clock +} + +// NewSqlStore constructs a new SQLStore from a BaseDB. The BaseDB is agnostic +// to the underlying driver which can be postgres or sqlite. +func NewSqlStore(db BaseDB, depositStore deposit.Store) *SqlStore { + return &SqlStore{ + baseDB: db, + depositStore: depositStore, + clock: clock.NewDefaultClock(), + } +} + +// CreateWithdrawal creates a static address withdrawal record in the database. +func (s *SqlStore) CreateWithdrawal(ctx context.Context, + deposits []*deposit.Deposit) error { + + id, err := GetRandomWithdrawalID() + if err != nil { + return err + } + + var totalAmount btcutil.Amount + for _, deposit := range deposits { + totalAmount += deposit.Value + } + + createArgs := sqlc.CreateWithdrawalParams{ + WithdrawalID: id[:], + TotalDepositAmount: int64(totalAmount), + InitiationTime: s.clock.Now().UTC(), + } + return s.baseDB.ExecTx(ctx, &loopdb.SqliteTxOptions{}, + func(q Querier) error { + err := q.CreateWithdrawal(ctx, createArgs) + if err != nil { + return err + } + + for _, deposit := range deposits { + err = q.CreateWithdrawalDeposit( + ctx, sqlc.CreateWithdrawalDepositParams{ + WithdrawalID: id[:], + DepositID: deposit.ID[:], + }) + if err != nil { + return err + } + } + + return nil + }) +} + +// UpdateWithdrawal updates a withdrawal record with the transaction +// information, including the withdrawn amount, change amount, and +// confirmation height. It is expected that the withdrawal has already been +// created with CreateWithdrawal, and that the deposits slice contains the +// deposits associated with the withdrawal. +func (s *SqlStore) UpdateWithdrawal(ctx context.Context, + deposits []*deposit.Deposit, tx *wire.MsgTx, confirmationHeight uint32, + changePkScript []byte) error { + + // Populate the optional change amount. + withdrawnAmount, changeAmount := int64(0), int64(0) + if len(tx.TxOut) == 1 { + withdrawnAmount = tx.TxOut[0].Value + } else if len(tx.TxOut) == 2 { + withdrawnAmount, changeAmount = tx.TxOut[0].Value, tx.TxOut[1].Value + if bytes.Equal(changePkScript, tx.TxOut[0].PkScript) { + changeAmount = tx.TxOut[0].Value + withdrawnAmount = tx.TxOut[1].Value + } + } + + updateArgs := sqlc.UpdateWithdrawalParams{ + WithdrawalTxID: sql.NullString{ + String: tx.TxHash().String(), + Valid: true, + }, + WithdrawnAmount: sql.NullInt64{ + Int64: withdrawnAmount, + Valid: withdrawnAmount > 0, + }, + ChangeAmount: sql.NullInt64{ + Int64: changeAmount, + Valid: changeAmount > 0, + }, + ConfirmationHeight: sql.NullInt64{ + Int64: int64(confirmationHeight), + Valid: confirmationHeight > 0, + }, + } + + return s.baseDB.ExecTx(ctx, &loopdb.SqliteTxOptions{}, + func(q Querier) error { + withdrawalID, err := q.GetWithdrawalIDByDepositID( + ctx, deposits[0].ID[:], + ) + if err != nil { + return err + } + + updateArgs.WithdrawalID = withdrawalID + err = q.UpdateWithdrawal(ctx, updateArgs) + if err != nil { + return err + } + + return nil + }) +} + +// GetAllWithdrawals retrieves all static address withdrawals from the +// database. It returns a slice of Withdrawal structs, each containing a list +// of associated deposits. +func (s *SqlStore) GetAllWithdrawals(ctx context.Context) ([]Withdrawal, + error) { + + withdrawals, err := s.baseDB.GetAllWithdrawals(ctx) + if err != nil { + return nil, err + } + + result := make([]Withdrawal, 0, len(withdrawals)) + for _, w := range withdrawals { + depositIDs, err := s.baseDB.GetWithdrawalDeposits(ctx, + w.WithdrawalID) + + if err != nil { + return nil, err + } + + deposits := make([]*deposit.Deposit, 0, len(depositIDs)) + for _, dID := range depositIDs { + deposit, err := s.depositStore.GetDeposit( + ctx, deposit.ID(dID), + ) + if err != nil { + return nil, err + } + deposits = append(deposits, deposit) + } + + txID, err := chainhash.NewHashFromStr(w.WithdrawalTxID.String) + if err != nil { + return nil, err + } + + result = append(result, Withdrawal{ + ID: ID(w.WithdrawalID), + TxID: *txID, + Deposits: deposits, + TotalDepositAmount: btcutil.Amount(w.TotalDepositAmount), + WithdrawnAmount: btcutil.Amount(w.WithdrawnAmount.Int64), + ChangeAmount: btcutil.Amount(w.ChangeAmount.Int64), + InitiationTime: w.InitiationTime, + ConfirmationHeight: w.ConfirmationHeight.Int64, + }) + } + + return result, nil +} diff --git a/staticaddr/withdraw/sql_store_test.go b/staticaddr/withdraw/sql_store_test.go new file mode 100644 index 00000000..5897f20e --- /dev/null +++ b/staticaddr/withdraw/sql_store_test.go @@ -0,0 +1,101 @@ +package withdraw + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/stretchr/testify/require" +) + +// TestSqlStore tests the basic functionality of the SQLStore. +func TestSqlStore(t *testing.T) { + ctxb := context.Background() + testDb := loopdb.NewTestDB(t) + defer testDb.Close() + + depositStore := deposit.NewSqlStore(testDb.BaseDB) + store := NewSqlStore(loopdb.NewTypedStore[Querier](testDb), depositStore) + + newID := func() deposit.ID { + did, err := deposit.GetRandomDepositID() + require.NoError(t, err) + + return did + } + + d1, d2 := &deposit.Deposit{ + ID: newID(), + Value: btcutil.Amount(100_000), + TimeOutSweepPkScript: []byte{ + 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x41, + }, + }, + &deposit.Deposit{ + ID: newID(), + Value: btcutil.Amount(200_000), + TimeOutSweepPkScript: []byte{ + 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x4d, + }, + } + + withdrawalTx := &wire.MsgTx{ + Version: 2, + TxOut: []*wire.TxOut{ + { + Value: int64(d1.Value + d2.Value - 100), + PkScript: []byte{ + 0x00, + }, + }, + { + Value: int64(100), + PkScript: []byte{ + 0x01, + }, + }, + }, + } + + err := depositStore.CreateDeposit(ctxb, d1) + require.NoError(t, err) + err = depositStore.CreateDeposit(ctxb, d2) + require.NoError(t, err) + + err = store.CreateWithdrawal(ctxb, []*deposit.Deposit{d1, d2}) + require.NoError(t, err) + + withdrawals, err := store.GetAllWithdrawals(ctxb) + require.NoError(t, err) + require.Len(t, withdrawals, 1) + require.NotEmpty(t, withdrawals[0].ID) + require.EqualValues( + t, d1.Value+d2.Value, withdrawals[0].TotalDepositAmount, + ) + require.Len(t, withdrawals[0].Deposits, 2) + require.EqualValues( + t, d1.Value, withdrawals[0].Deposits[0].Value, + ) + require.EqualValues( + t, d2.Value, withdrawals[0].Deposits[1].Value, + ) + require.NotEmpty(t, withdrawals[0].InitiationTime) + + err = store.UpdateWithdrawal( + ctxb, []*deposit.Deposit{d1, d2}, withdrawalTx, 6, []byte{0x01}, + ) + require.NoError(t, err) + + withdrawals, err = store.GetAllWithdrawals(ctxb) + require.NoError(t, err) + require.Len(t, withdrawals, 1) + require.NotEmpty(t, withdrawals[0].TxID) + require.EqualValues( + t, d1.Value+d2.Value-100, withdrawals[0].WithdrawnAmount, + ) + require.EqualValues(t, 100, withdrawals[0].ChangeAmount) + require.EqualValues(t, 6, withdrawals[0].ConfirmationHeight) +} diff --git a/staticaddr/withdraw/withdrawal.go b/staticaddr/withdraw/withdrawal.go new file mode 100644 index 00000000..9c32ec73 --- /dev/null +++ b/staticaddr/withdraw/withdrawal.go @@ -0,0 +1,68 @@ +package withdraw + +import ( + "crypto/rand" + "fmt" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/loop/staticaddr/deposit" +) + +const ( + IdLength = 32 +) + +// ID is a unique identifier for a deposit. +type ID [IdLength]byte + +// FromByteSlice creates a deposit id from a byte slice. +func (r *ID) FromByteSlice(b []byte) error { + if len(b) != IdLength { + return fmt.Errorf("withdrawal id must be 32 bytes, got %d, %x", + len(b), b) + } + + copy(r[:], b) + + return nil +} + +// Withdrawal represents a finalized static address withdrawal record in the +// database. +type Withdrawal struct { + // ID is the unique identifier of the deposit. + ID ID + + // TxID is the transaction ID of the withdrawal. + TxID chainhash.Hash + + // Deposits is a list of deposits used to fund the withdrawal. + Deposits []*deposit.Deposit + + // TotalDepositAmount is the total amount of all deposits used to fund + // the withdrawal. + TotalDepositAmount btcutil.Amount + + // WithdrawnAmount is the amount withdrawn. It represents the total + // value of selected deposits minus fees and change. + WithdrawnAmount btcutil.Amount + + // ChangeAmount is the optional change returned to the static address. + ChangeAmount btcutil.Amount + + // InitiationTime is the time at which the withdrawal was initiated. + InitiationTime time.Time + + // ConfirmationHeight is the block height at which the withdrawal was + // confirmed. + ConfirmationHeight int64 +} + +// GetRandomWithdrawalID generates a random withdrawal ID. +func GetRandomWithdrawalID() (ID, error) { + var id ID + _, err := rand.Read(id[:]) + return id, err +}