staticaddr: store historic withdrawal info

This commit is contained in:
Slyghtning 2025-06-02 15:49:03 +02:00
parent cd9bcdf6f7
commit 5718f11162
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
5 changed files with 437 additions and 9 deletions

View file

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

View file

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

View file

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

View file

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

View file

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